Updated on 2026-08-14
This commit is contained in:
commit
383f1abc4f
2127 changed files with 89632 additions and 12987 deletions
|
|
@ -10,11 +10,6 @@ plugins {
|
|||
android {
|
||||
namespace = "com.tangem.feature.swap.presentation"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Api */
|
||||
implementation(projects.features.commonFeatures.api)
|
||||
|
|
@ -61,6 +56,7 @@ dependencies {
|
|||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.card)
|
||||
implementation(projects.domain.visa)
|
||||
implementation(projects.domain.markets)
|
||||
implementation(projects.domain.swap)
|
||||
|
|
@ -72,8 +68,8 @@ dependencies {
|
|||
implementation(projects.features.swap.domain.models)
|
||||
implementation(projects.features.wallet.api)
|
||||
implementation(projects.features.swap.api)
|
||||
implementation(projects.features.sendV2.api)
|
||||
implementation(projects.features.sendV2.impl)
|
||||
implementation(projects.features.send.api)
|
||||
implementation(projects.features.send.impl)
|
||||
implementation(projects.features.feed.api)
|
||||
|
||||
/** AndroidX */
|
||||
|
|
@ -119,5 +115,4 @@ dependencies {
|
|||
|
||||
/** Test */
|
||||
testImplementation(projects.test.core)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
}
|
||||
|
|
@ -20,24 +20,18 @@ import com.tangem.core.decompose.context.AppComponentContext
|
|||
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.core.ui.utils.parseBigDecimalOrNull
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.isHotWallet
|
||||
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.model.SwapModel
|
||||
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
|
||||
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
|
||||
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.swap.SwapComponent
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
|
@ -50,7 +44,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
@Assisted appComponentContext: AppComponentContext,
|
||||
@Assisted private val params: SwapComponent.Params,
|
||||
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
|
||||
private val giveApprovalComponentFactory: GiveApprovalComponent.Factory,
|
||||
private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory,
|
||||
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
|
||||
) : SwapComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
|
|
@ -78,12 +72,10 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
source = model.approvalSlotNavigation,
|
||||
serializer = null,
|
||||
handleBackButton = true,
|
||||
childFactory = { _, factoryContext ->
|
||||
val approvalParams = getApprovalParams()
|
||||
?: error("Approval params are not available")
|
||||
giveApprovalComponentFactory.create(
|
||||
childFactory = { params, factoryContext ->
|
||||
giveApprovalEntryComponentFactory.create(
|
||||
context = childByContext(factoryContext),
|
||||
params = approvalParams,
|
||||
params = GiveApprovalEntryComponent.Params(params),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -123,6 +115,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY,
|
||||
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap,
|
||||
),
|
||||
isTransferMode = config.isTransferMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -143,6 +136,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
data class FeeSelectorConfig(
|
||||
val sendingCurrencyStatus: CryptoCurrencyStatus,
|
||||
val feeCurrencyStatus: CryptoCurrencyStatus,
|
||||
val isTransferMode: Boolean,
|
||||
)
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
|
|
@ -151,23 +145,22 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
|
||||
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } }
|
||||
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
|
||||
val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } }
|
||||
val shouldHideBlock by remember {
|
||||
derivedStateOf {
|
||||
// TODO collapse this and move to model
|
||||
val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero()
|
||||
val isInsufficientFunds = model.uiState.isInsufficientFunds
|
||||
val isProviderMissing = dataState.selectedProvider == null
|
||||
val loadedState = dataState.getCurrentLoadedSwapState()
|
||||
val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty
|
||||
val isInTransferMode = dataState.currentTransferState != null
|
||||
val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady)
|
||||
val isPermissionNotNeeded = model.isPermissionNotNeeded
|
||||
val isSwapNotReady = !isInTransferMode && (isProviderMissing || !isPermissionNotNeeded)
|
||||
val isTangemPayWithdrawal = model.isTangemPayWithdrawal()
|
||||
|
||||
isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady || isTangemPayWithdrawal
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) {
|
||||
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock, isInTransferMode) {
|
||||
if (shouldHideBlock) {
|
||||
TangemLogger.e(
|
||||
messageString = "Dismissing fee selector: " +
|
||||
|
|
@ -195,6 +188,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
FeeSelectorConfig(
|
||||
sendingCurrencyStatus = sendingCryptoCurrencyStatus,
|
||||
feeCurrencyStatus = feeCurrencyStatus,
|
||||
isTransferMode = isInTransferMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -247,34 +241,6 @@ internal class DefaultSwapComponent @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
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 = fromSwapCurrencyStatus.status,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrency,
|
||||
amount = model.dataState.amount.orEmpty(),
|
||||
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, fromSwapCurrencyStatus.currency.symbol),
|
||||
)
|
||||
},
|
||||
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
|
||||
isResetApproval = permissionState.isResetApproval,
|
||||
isHoldToConfirm = isHoldToConfirm,
|
||||
callback = model.approvalCallback,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onChildBack() {
|
||||
val isEmptyStack = childStack.value.backStack.isEmpty()
|
||||
val isSuccess = model.uiState.successState != null
|
||||
|
|
|
|||
|
|
@ -6,30 +6,50 @@ import com.tangem.features.swap.SwapFeatureToggles
|
|||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultSwapFeatureToggles @Inject constructor(
|
||||
featureTogglesManager: FeatureTogglesManager,
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
) : SwapFeatureToggles {
|
||||
|
||||
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
|
||||
)
|
||||
override val isYieldSwapEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.SWAP_INTEGRATED_APPROVE,
|
||||
)
|
||||
override val isSwapSwitchToTransferEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.SWAP_AB_ENABLED,
|
||||
)
|
||||
override val isSwapIntegratedApproveEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE,
|
||||
)
|
||||
|
||||
override val isSwapProviderFilterEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED,
|
||||
)
|
||||
override val isSwapAbEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.SWAP_AB_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapRateExperienceEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15103_SWAP_RATE_EXPERIENCE_ENABLED,
|
||||
)
|
||||
override val isSwapProviderFilterEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapPredefinedButtonsEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED,
|
||||
)
|
||||
override val isSwapRateExperienceEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15103_SWAP_RATE_EXPERIENCE_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapPredefinedButtonsEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED,
|
||||
)
|
||||
override val isExpressShareButtonEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15489_EXPRESS_SHARE_BUTTON_ENABLED,
|
||||
)
|
||||
|
||||
override val isSwapBestDexRateEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(
|
||||
toggle = FeatureToggles.AND_15715_SWAP_BEST_DEX_RATE_ENABLED,
|
||||
) && isSwapIntegratedApproveEnabled
|
||||
}
|
||||
|
|
@ -1,20 +1,23 @@
|
|||
package com.tangem.feature.swap.analytics
|
||||
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_BLOCKCHAIN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
|
||||
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
|
||||
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
|
||||
import com.tangem.core.analytics.models.getReferralParams
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
|
||||
|
|
@ -297,6 +300,65 @@ sealed class SwapEvents(
|
|||
event = "Fast amount input",
|
||||
params = mapOf("Percentage" to percent.toAnalyticsValue()),
|
||||
)
|
||||
|
||||
class TransferModeSwitched(
|
||||
fromCurrency: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
) : SwapEvents(
|
||||
event = "Transfer Mode Switched",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
|
||||
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
|
||||
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
|
||||
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
|
||||
),
|
||||
)
|
||||
|
||||
class ButtonTransferClicked(
|
||||
fromCurrency: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
) : SwapEvents(
|
||||
event = "Button - Transfer",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
|
||||
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
|
||||
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
|
||||
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("NullableToStringCall", "LongParameterList")
|
||||
class TransferInProgressScreen(
|
||||
fromCurrency: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
feeNetwork: Network,
|
||||
isTangemPay: Boolean,
|
||||
) : SwapEvents(
|
||||
event = "Transfer in Progress Screen Opened",
|
||||
params = mapOf(
|
||||
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
|
||||
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
|
||||
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
|
||||
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
|
||||
"Network fee" to feeNetwork.name,
|
||||
"Pay Account" to isTangemPay.toString(),
|
||||
),
|
||||
), AppsFlyerIncludedEvent
|
||||
|
||||
class ApproveGasOverrideError(
|
||||
fromTokenSymbol: String,
|
||||
fromTokenBlockchain: String,
|
||||
rpcProvider: String,
|
||||
error: String,
|
||||
) : SwapEvents(
|
||||
event = "Gas Estimation Override Error",
|
||||
params = mapOf(
|
||||
TOKEN_PARAM to fromTokenSymbol,
|
||||
BLOCKCHAIN to fromTokenBlockchain,
|
||||
"RPC Provider" to rpcProvider,
|
||||
ERROR_MESSAGE to error,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun PredefinedPercentAmount.toAnalyticsValue(): String = when (this) {
|
||||
|
|
|
|||
|
|
@ -12,18 +12,14 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.v2.api.params.FeeSelectorParams
|
||||
import com.tangem.features.send.api.FeeSelectorBlockComponent
|
||||
import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.params.FeeSelectorParams
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
class SwapFeeSelectorBlockComponent @AssistedInject constructor(
|
||||
@Assisted appComponentContext: AppComponentContext,
|
||||
|
|
@ -44,7 +40,11 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
|
|||
null
|
||||
},
|
||||
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
|
||||
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow,
|
||||
feeStateConfiguration = if (params.isTransferMode) {
|
||||
FeeSelectorParams.FeeStateConfiguration.None
|
||||
} else {
|
||||
FeeSelectorParams.FeeStateConfiguration.ExcludeLow
|
||||
},
|
||||
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
|
||||
cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus,
|
||||
analyticsCategoryName = params.analyticsParams.analyticsCategoryName,
|
||||
|
|
@ -100,6 +100,7 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
|
|||
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
val analyticsParams: AnalyticsParams,
|
||||
val repository: ModelRepository,
|
||||
val isTransferMode: Boolean,
|
||||
)
|
||||
|
||||
@AssistedFactory
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.model.consideredProvidersStates
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.models.states.ProviderState.AdditionalBadge
|
||||
import com.tangem.utils.isNullOrZero
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* Pure provider-level decisions for the swap UI:
|
||||
* - [findBest] — which provider is the "best" among the loaded quotes, and
|
||||
* - [resolveBadge] — which [ProviderState.AdditionalBadge] a provider row should show.
|
||||
*/
|
||||
internal object SwapProviderResolver {
|
||||
|
||||
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks the best provider among [states].
|
||||
*
|
||||
* When [isSwapBestDexRateEnabled] is on and at least one DEX/DEX_BRIDGE provider is present, the
|
||||
* best-rated DEX provider wins; otherwise the overall best-rated provider is returned (the best
|
||||
* CEX when no DEX is available). "Best rated" = lowest from/to fiat ratio (most output per unit
|
||||
* of input). Returns null when [states] is empty.
|
||||
*
|
||||
* @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on.
|
||||
*/
|
||||
fun findBest(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): SwapProvider? {
|
||||
if (!isSwapBestDexRateEnabled) return findBestRated(states)
|
||||
val dexStates = states.filterKeys { it.type.isDex() }
|
||||
return if (dexStates.isNotEmpty()) {
|
||||
findBestRated(dexStates)
|
||||
} else {
|
||||
findBestRated(states)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the badge for a single provider row.
|
||||
*
|
||||
* Priority: FCA restriction → permission required → recommended → best rate → none. A best-rate
|
||||
* badge is shown only when more than one provider is considered, FCA restrictions are not applied,
|
||||
* and this row's quote carries no price-impact warning. Which best-rate badge it is depends on the
|
||||
* provider mix (only relevant when [isSwapBestDexRateEnabled] is on):
|
||||
* - [AdditionalBadge.BestTrade] ("Best rate") — always on the overall best-rated provider,
|
||||
* regardless of its type.
|
||||
* - [AdditionalBadge.BestDexRate] ("Best DEX rate") — only when both CEX and DEX providers are
|
||||
* present and a CEX is the overall best (so the best DEX is not the overall best); it is then
|
||||
* shown on the best-rated DEX. When a DEX already is the overall best, or the set is CEX-only /
|
||||
* DEX-only, no separate "Best DEX rate" badge is shown.
|
||||
*
|
||||
* When [isSwapBestDexRateEnabled] is off, only the overall best provider gets [AdditionalBadge.BestTrade]
|
||||
* (legacy behaviour) and [AdditionalBadge.BestDexRate] is never produced.
|
||||
*
|
||||
* @param states all loaded quotes — used to find the best providers and to count considered providers.
|
||||
* @param provider the provider this row represents.
|
||||
* @param needApplyFCARestrictions whether FCA restrictions apply to the current user.
|
||||
* @param state this provider's [SwapState]; price-impact and permission are read from it when it
|
||||
* is a [SwapState.QuotesLoadedState]. Null for error rows (which only resolve to FCA / recommended / none).
|
||||
* @param isSwapBestDexRateEnabled whether the Best DEX Rate feature toggle is on.
|
||||
*/
|
||||
fun resolveBadge(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
provider: SwapProvider,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
state: SwapState? = null,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): AdditionalBadge {
|
||||
val priceImpact = (state as? SwapState.QuotesLoadedState)?.priceImpact
|
||||
val permissionState = (state as? SwapState.QuotesLoadedState)?.permissionState
|
||||
|
||||
val isNeedBestRateBadge = states.consideredProvidersStates().size > 1
|
||||
val isBestRateBadgeAllowed = !needApplyFCARestrictions && isNeedBestRateBadge &&
|
||||
priceImpact != null && !priceImpact.shouldShowWarning()
|
||||
|
||||
return when {
|
||||
needApplyFCARestrictions && provider.isFCARestricted() -> AdditionalBadge.FCAWarningList
|
||||
permissionState is PermissionDataState.PermissionRequired -> AdditionalBadge.PermissionRequired
|
||||
provider.isRecommended -> AdditionalBadge.Recommended
|
||||
isBestRateBadgeAllowed -> resolveBestRateBadge(states, provider, isSwapBestDexRateEnabled)
|
||||
else -> AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the best-rate badge for [provider] once it has passed the eligibility gate in [resolveBadge].
|
||||
* Returns [AdditionalBadge.Empty] when this row is neither the overall best nor the eligible best DEX.
|
||||
*/
|
||||
private fun resolveBestRateBadge(
|
||||
states: Map<SwapProvider, SwapState.QuotesLoadedState>,
|
||||
provider: SwapProvider,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
): AdditionalBadge {
|
||||
val overallBest = findBestRated(states)
|
||||
val isOverallBest = provider.providerId == overallBest?.providerId
|
||||
|
||||
// Toggle off → legacy behaviour: only the overall best provider gets the "Best rate" badge.
|
||||
if (!isSwapBestDexRateEnabled) {
|
||||
return if (isOverallBest) AdditionalBadge.BestTrade else AdditionalBadge.Empty
|
||||
}
|
||||
|
||||
val dexStates = states.filterKeys { it.type.isDex() }
|
||||
val hasDex = dexStates.isNotEmpty()
|
||||
val hasCex = states.keys.any { !it.type.isDex() }
|
||||
val bestDex = findBestRated(dexStates)
|
||||
val isBestDex = bestDex != null && provider.providerId == bestDex.providerId
|
||||
// Both types present and a CEX is the overall best (i.e. overall best != best DEX).
|
||||
val isCexBeatsDex = hasDex && hasCex && overallBest?.providerId != bestDex?.providerId
|
||||
|
||||
return when {
|
||||
isOverallBest -> AdditionalBadge.BestTrade
|
||||
isCexBeatsDex && isBestDex -> AdditionalBadge.BestDexRate
|
||||
else -> AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
/** Best provider following the default best-rate behaviour over all providers. */
|
||||
private fun findBestRated(states: Map<SwapProvider, SwapState.QuotesLoadedState>): SwapProvider? {
|
||||
return states.minByOrNull { entry -> entry.value.rateRatio() }?.key
|
||||
}
|
||||
|
||||
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
|
||||
|
||||
private fun SwapState.QuotesLoadedState.rateRatio(): BigDecimal {
|
||||
val fromAmountFiat = fromTokenInfo.amountFiat
|
||||
val toAmountFiat = toTokenInfo.amountFiat
|
||||
return if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) {
|
||||
fromAmountFiat.divide(
|
||||
toAmountFiat,
|
||||
toTokenInfo.swapCurrencyStatus.currency.decimals,
|
||||
RoundingMode.HALF_UP,
|
||||
)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,58 +7,39 @@ import com.tangem.core.ui.format.bigdecimal.crypto
|
|||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
|
||||
/**
|
||||
* Builds [ProviderState.Content] for the swap provider list / row.
|
||||
*
|
||||
* Pure: takes everything it needs as parameters. Designed to be unit-tested in isolation.
|
||||
*/
|
||||
internal object SwapProviderStateBuilder {
|
||||
|
||||
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
/**
|
||||
* Provider row on the main swap screen — shows the exchange rate `1 base ≈ rate quote`
|
||||
* (see [SwapRateFormatter]) and allows the user to open the provider picker.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun buildContentClickable(
|
||||
provider: SwapProvider,
|
||||
fromTokenInfo: TokenSwapInfo,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
permissionState: PermissionDataState,
|
||||
state: SwapState.QuotesLoadedState,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
isBestRate: Boolean,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
val rateString = SwapRateFormatter.formatRate(
|
||||
from = fromTokenInfo.swapCurrencyStatus.currency,
|
||||
to = toTokenInfo.swapCurrencyStatus.currency,
|
||||
fromAmount = fromTokenInfo.tokenAmount.value,
|
||||
toAmount = toTokenInfo.tokenAmount.value,
|
||||
from = state.fromTokenInfo.swapCurrencyStatus.currency,
|
||||
to = state.toTokenInfo.swapCurrencyStatus.currency,
|
||||
fromAmount = state.fromTokenInfo.tokenAmount.value,
|
||||
toAmount = state.toTokenInfo.tokenAmount.value,
|
||||
)
|
||||
return provider.toContent(
|
||||
subtitle = stringReference(rateString),
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = permissionState,
|
||||
isBestRate = isBestRate,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -70,28 +51,26 @@ internal object SwapProviderStateBuilder {
|
|||
@Suppress("LongParameterList")
|
||||
fun buildContentSelectable(
|
||||
provider: SwapProvider,
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
permissionState: PermissionDataState,
|
||||
state: SwapState.QuotesLoadedState,
|
||||
pricesLowerBest: Map<String, Float>,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
isBestRate: Boolean = false,
|
||||
isNeedBestRateBadge: Boolean = false,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
onApprovalSelectClick: (SwapProvider) -> Unit = {},
|
||||
): ProviderState.Content {
|
||||
return provider.toContent(
|
||||
subtitle = buildSelectableSubtitle(toTokenInfo),
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
permissionState = permissionState,
|
||||
isBestRate = isBestRate,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
),
|
||||
subtitle = buildSelectableSubtitle(state.toTokenInfo),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = pricesLowerBest[provider.providerId]
|
||||
?.let(PercentDifference::Value)
|
||||
?: PercentDifference.Value(0f),
|
||||
approvalSettings = when (state.permissionState) {
|
||||
is PermissionDataState.PermissionSettings -> ProviderState.ApprovalSettings.Content(
|
||||
onApprovalSelectClick = { onApprovalSelectClick(provider) },
|
||||
)
|
||||
else -> ProviderState.ApprovalSettings.Empty
|
||||
},
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -104,17 +83,15 @@ internal object SwapProviderStateBuilder {
|
|||
provider: SwapProvider,
|
||||
alertText: TextReference,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
return provider.toContent(
|
||||
subtitle = alertText,
|
||||
additionalBadge = resolveBadge(
|
||||
provider = provider,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
),
|
||||
additionalBadge = additionalBadge,
|
||||
selectionType = selectionType,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -130,32 +107,13 @@ internal object SwapProviderStateBuilder {
|
|||
return stringReference(toAmount)
|
||||
}
|
||||
|
||||
private fun resolveBadge(
|
||||
provider: SwapProvider,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
permissionState: PermissionDataState? = null,
|
||||
isBestRate: Boolean = false,
|
||||
isNeedBestRateBadge: Boolean = false,
|
||||
): ProviderState.AdditionalBadge {
|
||||
return when {
|
||||
needApplyFCARestrictions && provider.isFCARestricted() ->
|
||||
ProviderState.AdditionalBadge.FCAWarningList
|
||||
permissionState is PermissionDataState.PermissionRequired ->
|
||||
ProviderState.AdditionalBadge.PermissionRequired
|
||||
provider.isRecommended ->
|
||||
ProviderState.AdditionalBadge.Recommended
|
||||
isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions ->
|
||||
ProviderState.AdditionalBadge.BestTrade
|
||||
else ->
|
||||
ProviderState.AdditionalBadge.Empty
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun SwapProvider.toContent(
|
||||
subtitle: TextReference,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
percentLowerThenBest: PercentDifference,
|
||||
approvalSettings: ProviderState.ApprovalSettings,
|
||||
onProviderClick: (String) -> Unit,
|
||||
): ProviderState.Content {
|
||||
return ProviderState.Content(
|
||||
|
|
@ -169,8 +127,7 @@ internal object SwapProviderStateBuilder {
|
|||
percentLowerThenBest = percentLowerThenBest,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
onProviderClick = onProviderClick,
|
||||
approvalSettings = approvalSettings,
|
||||
)
|
||||
}
|
||||
|
||||
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
|
||||
}
|
||||
|
|
@ -47,6 +47,9 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
* @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
|
||||
* @param initialToCryptoCurrency optional currency to pre-select as TO. It is placed into the TO slot
|
||||
* ONLY if it already exists in the user's crypto portfolio (and the TO slot wasn't filled otherwise);
|
||||
* if the currency is not added to the wallet, the TO slot stays empty.
|
||||
* @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
|
|
@ -54,6 +57,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
initialCryptoCurrency: CryptoCurrency?,
|
||||
swapCurrencyPosition: CurrencyPosition,
|
||||
isPaymentAccount: Boolean,
|
||||
initialToCryptoCurrency: CryptoCurrency? = null,
|
||||
): Pair<SwapCurrencyStatus?, SwapCurrencyStatus?> {
|
||||
val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId)
|
||||
val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus ->
|
||||
|
|
@ -65,7 +69,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
|
||||
val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten()
|
||||
|
||||
return if (initialCryptoCurrency != null) {
|
||||
val (from, to) = if (initialCryptoCurrency != null) {
|
||||
val selectedSwapCurrencyStatus = if (isPaymentAccount) {
|
||||
cryptoPaymentAccounts
|
||||
} else {
|
||||
|
|
@ -91,6 +95,43 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
) to null
|
||||
}
|
||||
|
||||
val resolvedTo = to ?: resolveExplicitToCurrency(
|
||||
initialToCryptoCurrency = initialToCryptoCurrency,
|
||||
from = from,
|
||||
cryptoPortfolioAccountsMap = cryptoPortfolioAccounts,
|
||||
)
|
||||
|
||||
return from to resolvedTo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the optional explicit TO currency, but only if it is already present in the user's crypto
|
||||
* portfolio. Matches by token identity ([isSameTokenAs]) rather than full id, since the passed currency
|
||||
* may come from a different account/derivation. Prefers the instance from the FROM account, then falls
|
||||
* back to the first match across the portfolio. Never returns the same token as FROM.
|
||||
*/
|
||||
private fun resolveExplicitToCurrency(
|
||||
initialToCryptoCurrency: CryptoCurrency?,
|
||||
from: SwapCurrencyStatus?,
|
||||
cryptoPortfolioAccountsMap: Map<AccountStatus.CryptoPortfolio, List<SwapCurrencyStatus>>,
|
||||
): SwapCurrencyStatus? {
|
||||
if (initialToCryptoCurrency == null) return null
|
||||
|
||||
val fromCurrency = from?.currency
|
||||
fun matches(status: SwapCurrencyStatus): Boolean {
|
||||
return status.currency.isSameTokenAs(initialToCryptoCurrency) &&
|
||||
(fromCurrency == null || !status.currency.isSameTokenAs(fromCurrency))
|
||||
}
|
||||
|
||||
val fromAccountMatch = from?.account?.accountId?.let { fromAccountId ->
|
||||
cryptoPortfolioAccountsMap.entries
|
||||
.firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == fromAccountId }
|
||||
?.value
|
||||
?.firstOrNull(::matches)
|
||||
}
|
||||
|
||||
return fromAccountMatch ?: cryptoPortfolioAccountsMap.values.flatten().firstOrNull(::matches)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,6 +151,8 @@ internal class InitialCurrenciesResolver @Inject constructor(
|
|||
val currencyStatuses = when (accountStatus) {
|
||||
is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies()
|
||||
is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus)
|
||||
// Virtual account isn't a swap source in the MVP (withdrawal reuses the send flow)
|
||||
is AccountStatus.Virtual -> emptyList()
|
||||
}
|
||||
val availabilityStates = rampStateManager.availableForSwap(
|
||||
userWalletId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,7 @@ import com.tangem.domain.express.models.ExpressError
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
|
|
@ -78,6 +79,19 @@ internal class SwapNotificationsFactory(
|
|||
)
|
||||
}
|
||||
|
||||
fun getDestinationRequirementNotifications(
|
||||
requirement: AssetRequirementsCondition,
|
||||
onAssociateClick: () -> Unit,
|
||||
): ImmutableList<NotificationUM> {
|
||||
val notification = when (requirement) {
|
||||
is AssetRequirementsCondition.RequiredTrustline ->
|
||||
SwapNotificationUM.Warning.TokenTrustlineRequired(onAssociateClick)
|
||||
else ->
|
||||
SwapNotificationUM.Warning.TokenAssociationRequired(onAssociateClick)
|
||||
}
|
||||
return persistentListOf(notification)
|
||||
}
|
||||
|
||||
fun getQuotesErrorStateNotifications(
|
||||
expressDataError: ExpressDataError,
|
||||
fromToken: CryptoCurrency,
|
||||
|
|
@ -208,6 +222,7 @@ internal class SwapNotificationsFactory(
|
|||
cryptoCurrency = swapCurrencyStatus.currency,
|
||||
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
|
||||
isAccountFunded = true, // consider the account is funded on the provider side
|
||||
hasRequiredTrustline = false,
|
||||
)
|
||||
addReduceAmountNotification(
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
|
|
@ -249,7 +264,7 @@ internal class SwapNotificationsFactory(
|
|||
if (quoteModel.permissionState is PermissionDataState.PermissionRequired) {
|
||||
add(
|
||||
SwapNotificationUM.Info.PermissionNeeded(
|
||||
onApproveClick = actions.openPermissionBottomSheet,
|
||||
onApproveClick = actions.onApproveClick,
|
||||
onLearnMoreClick = { actions.onLinkClick(TangemSiteUrlBuilder.HELP_CENTER_SWAP_URL) },
|
||||
),
|
||||
)
|
||||
|
|
@ -346,6 +361,15 @@ internal class SwapNotificationsFactory(
|
|||
}
|
||||
|
||||
when (feeError) {
|
||||
is GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError -> {
|
||||
add(
|
||||
getWarningForError(
|
||||
expressDataError = ExpressDataError.TooLargeSolanaTransactionError(),
|
||||
fromToken = quoteModel.fromTokenInfo.swapCurrencyStatus.currency,
|
||||
onRetryClick = actions.onRetryClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
is GetFeeError.DataError -> {
|
||||
val error = feeError.cause
|
||||
if (error is ExpressDataError) {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@ package com.tangem.feature.swap.model
|
|||
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
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.SwapState
|
||||
import java.math.BigDecimal
|
||||
|
||||
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
|
||||
|
||||
data class SwapProcessDataState(
|
||||
// Initial network id
|
||||
val fromSwapCurrencyStatus: SwapCurrencyStatus? = null,
|
||||
|
|
@ -30,4 +33,28 @@ data class SwapProcessDataState(
|
|||
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {
|
||||
return lastLoadedSwapStates[selectedProvider] as? SwapState.QuotesLoadedState
|
||||
}
|
||||
|
||||
fun getLastLoadedSuccessStates(): SuccessLoadedSwapData {
|
||||
return lastLoadedSwapStates.filter { entry -> entry.value is SwapState.QuotesLoadedState }
|
||||
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
|
||||
return this.filter { entry -> entry.value is SwapState.QuotesLoadedState }
|
||||
.mapValues { entry -> entry.value as SwapState.QuotesLoadedState }
|
||||
}
|
||||
|
||||
internal fun Map<SwapProvider, SwapState>.consideredProvidersStates(): Map<SwapProvider, SwapState> {
|
||||
fun isUserResolvableError(swapState: SwapState): Boolean {
|
||||
return swapState is SwapState.SwapError &&
|
||||
(
|
||||
swapState.error is ExpressDataError.ExchangeTooSmallAmountError ||
|
||||
swapState.error is ExpressDataError.ExchangeTooBigAmountError
|
||||
)
|
||||
}
|
||||
|
||||
return this.filter { entry ->
|
||||
entry.value is SwapState.QuotesLoadedState || isUserResolvableError(entry.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.buttons.predefined.PredefinedPercentButtonUM
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
|
|
@ -16,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal data class SwapStateHolder(
|
||||
@param:StringRes val titleId: Int,
|
||||
val sendCardData: SwapCardState,
|
||||
val receiveCardData: SwapCardState,
|
||||
val notifications: ImmutableList<NotificationUM> = persistentListOf(),
|
||||
|
|
@ -46,6 +49,7 @@ internal data class SwapStateHolder(
|
|||
val onShowPermissionBottomSheet: () -> Unit = {},
|
||||
val onSwapUIModeChange: (SwapUIMode) -> Unit = {},
|
||||
val onSwapTypeMenuOpened: () -> Unit = {},
|
||||
val onTronBannerShown: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
|
@ -58,15 +62,16 @@ sealed class SwapCardState {
|
|||
val currencyIconState: CurrencyIconState,
|
||||
val tokenSymbol: TextReference,
|
||||
val amountEquivalent: TextReference?,
|
||||
val amountTextFieldValue: TextFieldValue?,
|
||||
val balance: TextReference,
|
||||
val isBalanceHidden: Boolean,
|
||||
val appCurrency: AppCurrency,
|
||||
val amountField: AmountFieldModel? = null,
|
||||
) : SwapCardState()
|
||||
|
||||
data class Empty(
|
||||
override val type: TransactionCardType,
|
||||
val amountEquivalent: TextReference,
|
||||
val amountTextFieldValue: TextFieldValue?,
|
||||
val amountField: AmountFieldModel? = null,
|
||||
) : SwapCardState()
|
||||
|
||||
data class Loading(
|
||||
|
|
@ -99,11 +104,12 @@ sealed interface TransactionCardType {
|
|||
val inputError: InputError
|
||||
|
||||
data class Inputtable(
|
||||
val onAmountChanged: ((String) -> Unit),
|
||||
val onFocusChanged: ((Boolean) -> Unit),
|
||||
override val inputError: InputError,
|
||||
override val accountTitleUM: AccountTitleUM,
|
||||
val isEnabled: Boolean,
|
||||
/** Switches the input field between crypto and fiat entry. Argument is the new `isFiatValue`. */
|
||||
val onCurrencyChange: (Boolean) -> Unit = {},
|
||||
) : TransactionCardType
|
||||
|
||||
data class ReadOnly(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.feature.swap.models
|
||||
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.navigationButtons.NavigationUM
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
|
|
@ -22,7 +23,7 @@ data class SwapSuccessStateHolder(
|
|||
val toTokenFiatAmount: TextReference,
|
||||
val fromTokenIconState: CurrencyIconState?,
|
||||
val toTokenIconState: CurrencyIconState?,
|
||||
val onExploreButtonClick: () -> Unit,
|
||||
val navigationUM: NavigationUM,
|
||||
val onStatusButtonClick: () -> Unit,
|
||||
) {
|
||||
val shouldShowProvider: Boolean
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.express.models.ProviderFilterType
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal data class UiActions(
|
||||
val onAmountChanged: (String) -> Unit,
|
||||
val onCurrencyChange: (Boolean) -> Unit,
|
||||
val onAmountSelected: (Boolean) -> Unit,
|
||||
val onSwapClick: () -> Unit,
|
||||
val onTransferClick: () -> Unit,
|
||||
|
|
@ -18,7 +20,8 @@ internal data class UiActions(
|
|||
val onPredefinedPercentSelected: (PredefinedPercentAmount) -> Unit,
|
||||
val onReduceToAmount: (SwapAmount) -> Unit,
|
||||
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
|
||||
val openPermissionBottomSheet: () -> Unit,
|
||||
val onApproveClick: () -> Unit,
|
||||
val onApproveTypeSelect: (SwapProvider) -> Unit,
|
||||
// region new actions
|
||||
val onRetryClick: () -> Unit,
|
||||
val onProviderClick: (String) -> Unit,
|
||||
|
|
@ -31,4 +34,5 @@ internal data class UiActions(
|
|||
val onReceiveCardWarningClick: () -> Unit,
|
||||
val onSwapUIModeChange: (SwapUIMode) -> Unit,
|
||||
val onSwapTypeMenuOpened: () -> Unit,
|
||||
val onTronBannerShown: () -> Unit,
|
||||
)
|
||||
|
|
@ -29,6 +29,7 @@ sealed class ProviderState {
|
|||
val additionalBadge: AdditionalBadge,
|
||||
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
|
||||
val namePrefix: PrefixType,
|
||||
val approvalSettings: ApprovalSettings = ApprovalSettings.Empty,
|
||||
override val onProviderClick: (String) -> Unit,
|
||||
) : ProviderState()
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ sealed class ProviderState {
|
|||
sealed class AdditionalBadge {
|
||||
data object FCAWarningList : AdditionalBadge()
|
||||
data object BestTrade : AdditionalBadge()
|
||||
data object BestDexRate : AdditionalBadge()
|
||||
data object Empty : AdditionalBadge()
|
||||
data object PermissionRequired : AdditionalBadge()
|
||||
data object Recommended : AdditionalBadge()
|
||||
|
|
@ -61,6 +63,14 @@ sealed class ProviderState {
|
|||
enum class PrefixType {
|
||||
NONE, PROVIDED_BY
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class ApprovalSettings {
|
||||
data object Empty : ApprovalSettings()
|
||||
data class Content(
|
||||
val onApprovalSelectClick: () -> Unit,
|
||||
) : ApprovalSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -149,6 +149,28 @@ internal object SwapNotificationUM {
|
|||
),
|
||||
)
|
||||
|
||||
data class TokenAssociationRequired(
|
||||
val onAssociateClick: () -> Unit,
|
||||
) : Warning(
|
||||
title = resourceReference(R.string.warning_hedera_missing_token_association_title),
|
||||
subtitle = resourceReference(R.string.warning_receive_blocked_hedera_token_association_required_message),
|
||||
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
|
||||
text = resourceReference(R.string.warning_hedera_missing_token_association_button_title),
|
||||
onClick = onAssociateClick,
|
||||
),
|
||||
)
|
||||
|
||||
data class TokenTrustlineRequired(
|
||||
val onAssociateClick: () -> Unit,
|
||||
) : Warning(
|
||||
title = resourceReference(R.string.warning_token_trustline_title),
|
||||
subtitle = resourceReference(R.string.warning_receive_blocked_token_trustline_required_message),
|
||||
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
|
||||
text = resourceReference(R.string.warning_token_trustline_button_title),
|
||||
onClick = onAssociateClick,
|
||||
),
|
||||
)
|
||||
|
||||
data class NeedReserveToCreateAccount(
|
||||
val receiveAmount: String,
|
||||
val receiveToken: String,
|
||||
|
|
@ -251,5 +273,10 @@ internal object SwapNotificationUM {
|
|||
onClick = onApproveClick,
|
||||
),
|
||||
)
|
||||
|
||||
data object TronTokenFee : Info(
|
||||
title = resourceReference(R.string.tron_will_be_send_token_fee_title),
|
||||
subtitle = resourceReference(R.string.tron_will_be_send_token_fee_description),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference
|
|||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.models.SwapSuccessStateHolder
|
||||
import com.tangem.feature.swap.ui.swapSuccessNavigation
|
||||
|
||||
internal data object SwapSuccessStatePreview {
|
||||
val state = SwapSuccessStateHolder(
|
||||
|
|
@ -37,7 +38,7 @@ internal data object SwapSuccessStatePreview {
|
|||
fromTokenIconState = CurrencyIconState.Loading,
|
||||
toTokenIconState = CurrencyIconState.Loading,
|
||||
rate = TextReference.Str("1 000 DAI ~ 1 000 MATIC"),
|
||||
onExploreButtonClick = {},
|
||||
navigationUM = swapSuccessNavigation(txUrl = "https://www.google.com/#q=nam", exploreClick = {}),
|
||||
onStatusButtonClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.ParagraphIntrinsics
|
||||
import androidx.compose.ui.text.font.createFontFamilyResolver
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Suppress("MagicNumber", "LongMethod")
|
||||
@Composable
|
||||
internal fun AutoSizeTextField(
|
||||
textFieldValue: TextFieldValue,
|
||||
focusRequester: FocusRequester,
|
||||
isEnabled: Boolean,
|
||||
onAmountChange: (String) -> Unit,
|
||||
onFocusChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LaunchedEffect(isEnabled) {
|
||||
if (!isEnabled) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
var shrunkFontSize = TangemTheme.typography.h2.fontSize
|
||||
val calculateIntrinsics = @Composable {
|
||||
ParagraphIntrinsics(
|
||||
text = textFieldValue.text,
|
||||
style = TangemTheme.typography.h2.copy(
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
fontSize = shrunkFontSize,
|
||||
),
|
||||
density = LocalDensity.current,
|
||||
fontFamilyResolver = createFontFamilyResolver(LocalContext.current),
|
||||
)
|
||||
}
|
||||
|
||||
var intrinsics = calculateIntrinsics()
|
||||
with(LocalDensity.current) {
|
||||
while (intrinsics.maxIntrinsicWidth > maxWidth.toPx()) {
|
||||
shrunkFontSize *= 0.9f
|
||||
intrinsics = calculateIntrinsics()
|
||||
}
|
||||
}
|
||||
val customTextSelectionColors = TextSelectionColors(
|
||||
handleColor = Color.Transparent,
|
||||
backgroundColor = TangemTheme.colors.text.secondary.copy(alpha = 0.4f),
|
||||
)
|
||||
CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) {
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
onAmountChange.invoke(it.text)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { onFocusChange(it.hasFocus) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Decimal,
|
||||
),
|
||||
enabled = isEnabled,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
decorationBox = { innerTextField ->
|
||||
if (textFieldValue.text.isBlank()) {
|
||||
Text(
|
||||
text = "0",
|
||||
color = TangemTheme.colors.text.disabled,
|
||||
style = TangemTheme.typography.h2,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
},
|
||||
textStyle = TangemTheme.typography.h2.copy(
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
fontSize = shrunkFontSize,
|
||||
),
|
||||
cursorBrush = SolidColor(TangemTheme.colors.text.primary1),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,6 +178,7 @@ private fun Preview_ChooseProviderBottomSheet() {
|
|||
percentLowerThenBest = PercentDifference.Value(-1.0f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = {},
|
||||
),
|
||||
ProviderState.Unavailable(
|
||||
|
|
|
|||
|
|
@ -4,26 +4,32 @@ import android.content.res.Configuration
|
|||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.SpacerWMax
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
|
|
@ -154,6 +160,7 @@ private fun ProviderContentState(
|
|||
when (state.additionalBadge) {
|
||||
ProviderState.AdditionalBadge.FCAWarningList -> FCABadgeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.BestTrade -> BestTradeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.BestDexRate -> BestDexRateItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.PermissionRequired -> PermissionBadgeItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.Recommended -> RecommendedItem(badgeModifier)
|
||||
ProviderState.AdditionalBadge.Empty -> Unit
|
||||
|
|
@ -194,6 +201,23 @@ private fun ProviderContentState(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.approvalSettings is ProviderState.ApprovalSettings.Content) {
|
||||
SpacerWMax()
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_filter_default_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.icon.informative,
|
||||
modifier = Modifier
|
||||
.padding(end = 14.dp)
|
||||
.size(20.dp)
|
||||
.clickable(
|
||||
indication = ripple(false),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = state.approvalSettings.onApprovalSelectClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
|
||||
|
|
@ -380,6 +404,24 @@ private fun BestTradeItem(modifier: Modifier = Modifier) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BestDexRateItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f),
|
||||
shape = TangemTheme.shapes.roundedCornersLarge,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.express_provider_best_dex_rate),
|
||||
style = TangemTheme.typography.caption1,
|
||||
color = TangemTheme.colors.icon.accent,
|
||||
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing6),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionBadgeItem(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
|
|
@ -442,10 +484,9 @@ private fun ProviderItemPreview(
|
|||
@PreviewParameter(ProviderItemParameterProvider::class) state: Pair<ProviderState, Boolean>,
|
||||
) {
|
||||
TangemThemePreview {
|
||||
ProviderItem(
|
||||
ProviderItemBlock(
|
||||
modifier = Modifier.background(TangemTheme.colors.background.action),
|
||||
state = state.first,
|
||||
isSelected = state.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -460,22 +501,28 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
|
|||
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
selectionType = ProviderState.SelectionType.NONE,
|
||||
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = {},
|
||||
)
|
||||
val contentState2 = contentState.copy(
|
||||
val contentStatePermissionRequired = contentState.copy(
|
||||
subtitle = stringReference(value = "1 132,46 MATIC"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
|
||||
percentLowerThenBest = PercentDifference.Value(value = 5f),
|
||||
)
|
||||
val contentStatePermissionIntegrated = contentState.copy(
|
||||
subtitle = stringReference(value = "1 132,46 MATIC"),
|
||||
percentLowerThenBest = PercentDifference.Value(value = 5f),
|
||||
approvalSettings = ProviderState.ApprovalSettings.Content({}),
|
||||
)
|
||||
val unavailableState = ProviderState.Unavailable(
|
||||
id = "1",
|
||||
name = "1inch",
|
||||
type = "DEX",
|
||||
iconUrl = "",
|
||||
alertText = stringReference(value = "Not available"),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
selectionType = ProviderState.SelectionType.NONE,
|
||||
onProviderClick = {},
|
||||
)
|
||||
val loadingState = ProviderState.Loading()
|
||||
|
|
@ -483,8 +530,11 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
|
|||
add(contentState to true)
|
||||
add(contentState to false)
|
||||
|
||||
add(contentState2 to true)
|
||||
add(contentState2 to false)
|
||||
add(contentStatePermissionRequired to true)
|
||||
add(contentStatePermissionRequired to false)
|
||||
|
||||
add(contentStatePermissionIntegrated to true)
|
||||
add(contentStatePermissionIntegrated to false)
|
||||
|
||||
add(unavailableState to true)
|
||||
add(unavailableState to false)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,9 @@ private fun SimpleProviderTrailing(state: ProviderState) {
|
|||
.size(TangemTheme.dimens.size20)
|
||||
.clip(RoundedCornerShape(TangemTheme.dimens.radius4)),
|
||||
)
|
||||
if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade) {
|
||||
if (state.additionalBadge is ProviderState.AdditionalBadge.BestTrade ||
|
||||
state.additionalBadge is ProviderState.AdditionalBadge.BestDexRate
|
||||
) {
|
||||
SimpleBestRateBadge(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
|
|
@ -173,6 +175,7 @@ private class SimpleProviderPreview : PreviewParameterProvider<ProviderState> {
|
|||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
percentLowerThenBest = PercentDifference.Empty,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
approvalSettings = ProviderState.ApprovalSettings.Empty,
|
||||
onProviderClick = {},
|
||||
),
|
||||
ProviderState.Content(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
|
||||
|
|
@ -29,8 +33,11 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
|||
import com.tangem.domain.models.wallet.isHotWallet
|
||||
import com.tangem.domain.swap.models.PredefinedPercentAmount
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.converters.SwapProviderResolver
|
||||
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
|
|
@ -38,12 +45,13 @@ import com.tangem.feature.swap.domain.models.domain.*
|
|||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.model.SwapNotificationsFactory
|
||||
import com.tangem.feature.swap.model.SwapProcessDataState
|
||||
import com.tangem.feature.swap.model.getLastLoadedSuccessStates
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.SwapButton.Mode
|
||||
import com.tangem.feature.swap.models.states.*
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.utils.formatToUIRepresentation
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.StringsSigns
|
||||
|
|
@ -70,6 +78,10 @@ internal class StateBuilder(
|
|||
) {
|
||||
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
|
||||
|
||||
private val amountScreenClickIntents by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SwapAmountScreenClickIntents(actions)
|
||||
}
|
||||
|
||||
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SwapNotificationsFactory(
|
||||
actions = actions,
|
||||
|
|
@ -80,6 +92,7 @@ internal class StateBuilder(
|
|||
|
||||
fun createInitialLoadingState(swapUIMode: SwapUIMode = SwapUIMode.Detailed): SwapStateHolder {
|
||||
return SwapStateHolder(
|
||||
titleId = R.string.common_swap,
|
||||
sendCardData = getEmptyCardState(
|
||||
isFromCard = true,
|
||||
emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY),
|
||||
|
|
@ -100,7 +113,7 @@ internal class StateBuilder(
|
|||
onChangeCardsClicked = actions.onChangeCardsClicked,
|
||||
onMaxAmountSelected = actions.onMaxAmountSelected,
|
||||
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
|
||||
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
|
||||
onShowPermissionBottomSheet = actions.onApproveClick,
|
||||
onSelectTokenClick = actions.onSelectTokenClick,
|
||||
onSuccess = actions.onSuccess,
|
||||
providerState = ProviderState.Empty(),
|
||||
|
|
@ -110,6 +123,7 @@ internal class StateBuilder(
|
|||
swapUIMode = swapUIMode,
|
||||
onSwapUIModeChange = actions.onSwapUIModeChange,
|
||||
onSwapTypeMenuOpened = actions.onSwapTypeMenuOpened,
|
||||
onTronBannerShown = actions.onTronBannerShown,
|
||||
shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled,
|
||||
)
|
||||
}
|
||||
|
|
@ -141,10 +155,15 @@ internal class StateBuilder(
|
|||
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
|
||||
onClick = { },
|
||||
),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency),
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
emptyAmountState.isTransferMode,
|
||||
),
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
fromToken = fromSwapCurrencyStatus?.currency,
|
||||
toCurrency = toSwapCurrencyStatus?.currency,
|
||||
isTransferMode = emptyAmountState.isTransferMode,
|
||||
),
|
||||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = ProviderState.Empty(),
|
||||
|
|
@ -194,9 +213,10 @@ internal class StateBuilder(
|
|||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
return uiStateHolder.copy(
|
||||
titleId = R.string.common_swap,
|
||||
sendCardData = uiStateHolder.sendCardData.copy(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
|
|
@ -231,6 +251,11 @@ internal class StateBuilder(
|
|||
toSwapCurrencyStatus: SwapCurrencyStatus?,
|
||||
shouldResetAmount: Boolean,
|
||||
): SwapStateHolder {
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
emptyAmountState.isTransferMode,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
sendCardData = uiStateHolder.sendCardData.updateCurrencyStatus(
|
||||
swapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
|
|
@ -254,10 +279,11 @@ internal class StateBuilder(
|
|||
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
|
||||
onClick = { },
|
||||
),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount,
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
fromToken = fromSwapCurrencyStatus?.currency,
|
||||
toCurrency = toSwapCurrencyStatus?.currency,
|
||||
isTransferMode = emptyAmountState.isTransferMode,
|
||||
),
|
||||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = ProviderState.Empty(),
|
||||
|
|
@ -274,7 +300,7 @@ internal class StateBuilder(
|
|||
): SwapCardState {
|
||||
val cardType = if (isFromCard) {
|
||||
TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true),
|
||||
|
|
@ -295,17 +321,17 @@ internal class StateBuilder(
|
|||
)
|
||||
} else if (shouldResetAmount) {
|
||||
copy(
|
||||
amountTextFieldValue = if (isFromCard) {
|
||||
null
|
||||
} else {
|
||||
TextFieldValue("0".appendApproximateSign())
|
||||
},
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
type = cardType,
|
||||
amountField = if (isFromCard) {
|
||||
emptyAmountField(swapCurrencyStatus)
|
||||
} else {
|
||||
displayAmountField("0".appendApproximateSign(), swapCurrencyStatus)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
copy(
|
||||
|
|
@ -314,10 +340,66 @@ internal class StateBuilder(
|
|||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
type = cardType,
|
||||
amountField = if (isFromCard) {
|
||||
if (amountField == null) {
|
||||
emptyAmountField(swapCurrencyStatus)
|
||||
} else {
|
||||
buildAmountField(
|
||||
amountRaw = amountField.cryptoAmount.value?.toPlainString().orEmpty(),
|
||||
fieldValue = amountField.value,
|
||||
isFiatValue = amountField.isFiatValue,
|
||||
fromSwapCurrencyStatus = swapCurrencyStatus,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
amountField
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyAmountField(fromSwapCurrencyStatus: SwapCurrencyStatus): AmountFieldModel = buildAmountField(
|
||||
amountRaw = "",
|
||||
fieldValue = "",
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds the read-only receive card [AmountFieldModel] from a display [value]. Reuses the existing
|
||||
* converter path; the read-only UI only reads [AmountFieldModel.value].
|
||||
*/
|
||||
private fun displayAmountField(value: String, status: SwapCurrencyStatus): AmountFieldModel = buildAmountField(
|
||||
amountRaw = "",
|
||||
fieldValue = value,
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = status,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a status-free placeholder [AmountFieldModel] for the static [SwapCardState.Empty] card,
|
||||
* which has no [SwapCurrencyStatus]. The Empty card UI only reads [AmountFieldModel.value].
|
||||
*/
|
||||
private fun placeholderAmountField(value: String): AmountFieldModel = AmountFieldModel(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions(),
|
||||
cryptoAmount = Amount(currencySymbol = "", value = BigDecimal.ZERO, decimals = 0),
|
||||
fiatAmount = Amount(currencySymbol = "", value = BigDecimal.ZERO, decimals = 0),
|
||||
isFiatValue = false,
|
||||
fiatValue = "",
|
||||
isFiatUnavailable = false,
|
||||
isValuePasted = false,
|
||||
onValuePastedTriggerDismiss = {},
|
||||
isError = false,
|
||||
isWarning = false,
|
||||
error = TextReference.EMPTY,
|
||||
)
|
||||
|
||||
private fun createCardState(
|
||||
swapCurrencyStatus: SwapCurrencyStatus?,
|
||||
emptyAmountState: SwapState.EmptyAmountState,
|
||||
|
|
@ -330,7 +412,7 @@ internal class StateBuilder(
|
|||
SwapCardState.SwapCardData(
|
||||
type = if (isFromCard) {
|
||||
TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true),
|
||||
|
|
@ -342,16 +424,17 @@ internal class StateBuilder(
|
|||
accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, false),
|
||||
)
|
||||
},
|
||||
amountTextFieldValue = if (isFromCard) {
|
||||
null
|
||||
} else {
|
||||
TextFieldValue("0".appendApproximateSign())
|
||||
},
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
amountField = if (isFromCard) {
|
||||
emptyAmountField(swapCurrencyStatus)
|
||||
} else {
|
||||
displayAmountField("0".appendApproximateSign(), swapCurrencyStatus)
|
||||
},
|
||||
appCurrency = appCurrencyProvider(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -366,7 +449,7 @@ internal class StateBuilder(
|
|||
),
|
||||
),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(text = if (isFromCard) "0" else "0".appendApproximateSign()),
|
||||
amountField = placeholderAmountField(value = if (isFromCard) "0" else "0".appendApproximateSign()),
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
)
|
||||
|
||||
|
|
@ -374,6 +457,34 @@ internal class StateBuilder(
|
|||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): SwapStateHolder = createBlockedSwapState(
|
||||
uiStateHolder = uiStateHolder,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
|
||||
)
|
||||
|
||||
fun createDestinationRequirementBlockedState(
|
||||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
requirement: AssetRequirementsCondition,
|
||||
onAssociateClick: () -> Unit,
|
||||
): SwapStateHolder = createBlockedSwapState(
|
||||
uiStateHolder = uiStateHolder,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
notifications = notificationsFactory.getDestinationRequirementNotifications(
|
||||
requirement = requirement,
|
||||
onAssociateClick = onAssociateClick,
|
||||
),
|
||||
)
|
||||
|
||||
private fun createBlockedSwapState(
|
||||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
notifications: ImmutableList<NotificationUM>,
|
||||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
return uiStateHolder.copy(
|
||||
|
|
@ -381,29 +492,27 @@ internal class StateBuilder(
|
|||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = "0",
|
||||
),
|
||||
amountField = displayAmountField(value = "0", status = fromSwapCurrencyStatus),
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
|
||||
balance = fromSwapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
receiveCardData = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = "0",
|
||||
),
|
||||
amountField = displayAmountField(value = "0", status = toSwapCurrencyStatus),
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
|
||||
balance = toSwapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
|
||||
notifications = notifications,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = false,
|
||||
|
|
@ -429,7 +538,7 @@ internal class StateBuilder(
|
|||
return uiStateHolder.copy(
|
||||
sendCardData = uiStateHolder.sendCardData.copy(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
|
|
@ -440,7 +549,7 @@ internal class StateBuilder(
|
|||
type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
|
||||
),
|
||||
amountTextFieldValue = null,
|
||||
amountField = null,
|
||||
amountEquivalent = null,
|
||||
),
|
||||
notifications = persistentListOf(),
|
||||
|
|
@ -465,9 +574,7 @@ internal class StateBuilder(
|
|||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
swapProvider: SwapProvider,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
swapFee: SwapFee?,
|
||||
feeError: FeeSelectorUM.Error?,
|
||||
): SwapStateHolder {
|
||||
|
|
@ -512,12 +619,13 @@ internal class StateBuilder(
|
|||
return uiStateHolder.copy(
|
||||
sendCardData = SwapCardState.SwapCardData(
|
||||
type = sendInput,
|
||||
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
|
||||
amountEquivalent = uiStateHolder.sendCardData.amountEquivalent,
|
||||
currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol),
|
||||
balance = fromSwapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
amountField = uiStateHolder.sendCardData.amountField,
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
receiveCardData = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.ReadOnly(
|
||||
|
|
@ -525,10 +633,11 @@ internal class StateBuilder(
|
|||
onWarningClick = actions.onReceiveCardWarningClick,
|
||||
accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
quoteModel.toTokenInfo.tokenAmount
|
||||
amountField = displayAmountField(
|
||||
value = quoteModel.toTokenInfo.tokenAmount
|
||||
.formatToUIRepresentation()
|
||||
.appendApproximateSign(),
|
||||
status = toSwapCurrencyStatus,
|
||||
),
|
||||
amountEquivalent = if (priceImpact.type.ordinal > PriceImpact.Type.LOW.ordinal) {
|
||||
combinedReference(
|
||||
|
|
@ -554,6 +663,7 @@ internal class StateBuilder(
|
|||
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
|
||||
balance = toSwapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
isInsufficientFunds = isInsufficientFundsCondition(quoteModel),
|
||||
notifications = notifications,
|
||||
|
|
@ -574,13 +684,9 @@ internal class StateBuilder(
|
|||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = swapProvider,
|
||||
fromTokenInfo = quoteModel.fromTokenInfo,
|
||||
toTokenInfo = quoteModel.toTokenInfo,
|
||||
permissionState = quoteModel.permissionState,
|
||||
state = quoteModel,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
),
|
||||
priceImpact = priceImpact,
|
||||
|
|
@ -618,7 +724,12 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean {
|
||||
private fun shouldShowMaxAmount(
|
||||
fromToken: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
isTransferMode: Boolean = false,
|
||||
): Boolean {
|
||||
if (isTransferMode) return true
|
||||
return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id)
|
||||
}
|
||||
|
||||
|
|
@ -631,9 +742,10 @@ internal class StateBuilder(
|
|||
private fun createPredefinedButtons(
|
||||
fromToken: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
isTransferMode: Boolean = false,
|
||||
): ImmutableList<PredefinedPercentButtonUM> {
|
||||
if (!swapFeatureToggles.isSwapPredefinedButtonsEnabled) return persistentListOf()
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency)
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency, isTransferMode)
|
||||
return PredefinedPercentAmount.entries
|
||||
.filter { it != PredefinedPercentAmount.MAX || shouldShowMaxAmount }
|
||||
.map { percent ->
|
||||
|
|
@ -702,7 +814,7 @@ internal class StateBuilder(
|
|||
toSwapCurrencyStatus: SwapCurrencyStatus?,
|
||||
balanceStatus: SwapBalanceStatus,
|
||||
expressDataError: ExpressDataError,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
swapFee: SwapFee?,
|
||||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
|
|
@ -722,7 +834,7 @@ internal class StateBuilder(
|
|||
expressDataError = expressDataError,
|
||||
onProviderClick = actions.onProviderClick,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
val type = TransactionCardType.ReadOnly(
|
||||
accountTitleUM = getCardAccountTitle(
|
||||
|
|
@ -733,19 +845,18 @@ internal class StateBuilder(
|
|||
val receiveCardData = toSwapCurrencyStatus?.status?.let { toToken ->
|
||||
SwapCardState.SwapCardData(
|
||||
type = type,
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = "0",
|
||||
),
|
||||
amountField = displayAmountField(value = "0", status = toSwapCurrencyStatus),
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
|
||||
tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol),
|
||||
balance = toToken.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
)
|
||||
} ?: SwapCardState.Empty(
|
||||
type = type,
|
||||
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
|
||||
amountTextFieldValue = null,
|
||||
amountField = null,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
receiveCardData = receiveCardData,
|
||||
|
|
@ -771,7 +882,7 @@ internal class StateBuilder(
|
|||
expressDataError: ExpressDataError,
|
||||
onProviderClick: (String) -> Unit,
|
||||
selectionType: ProviderState.SelectionType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
): ProviderState {
|
||||
return when (expressDataError) {
|
||||
is ExpressDataError.ExchangeTooSmallAmountError -> {
|
||||
|
|
@ -782,7 +893,7 @@ internal class StateBuilder(
|
|||
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
|
||||
),
|
||||
selectionType = selectionType,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -794,7 +905,7 @@ internal class StateBuilder(
|
|||
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
|
||||
),
|
||||
selectionType = selectionType,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
}
|
||||
|
|
@ -813,11 +924,10 @@ internal class StateBuilder(
|
|||
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
return uiStateHolder.copy(
|
||||
sendCardData = uiStateHolder.sendCardData.copy(
|
||||
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
),
|
||||
receiveCardData = uiStateHolder.receiveCardData.copy(
|
||||
amountTextFieldValue = TextFieldValue("0"),
|
||||
amountField = uiStateHolder.receiveCardData.amountField?.copy(value = "0"),
|
||||
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
|
||||
),
|
||||
notifications = persistentListOf(),
|
||||
|
|
@ -854,19 +964,58 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the shared [AmountFieldModel] that carries the "from" card input state.
|
||||
*
|
||||
* @param amountRaw authoritative crypto amount (ungrouped) — drives [AmountFieldModel.cryptoAmount].
|
||||
* @param fieldValue value currently shown in the input field, expressed in the active currency.
|
||||
* @param isFiatValue whether the active input currency is fiat.
|
||||
*/
|
||||
private fun buildAmountField(
|
||||
amountRaw: String,
|
||||
fieldValue: String,
|
||||
isFiatValue: Boolean,
|
||||
isPastedAmount: Boolean,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): AmountFieldModel {
|
||||
val appCurrency = appCurrencyProvider()
|
||||
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
|
||||
val cryptoDecimal = amountRaw.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
val fiatValue = fiatRate?.multiply(cryptoDecimal).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
|
||||
return AmountFieldConverter(
|
||||
clickIntents = amountScreenClickIntents,
|
||||
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||
appCurrency = appCurrency,
|
||||
).convert(value = amountRaw).copy(
|
||||
value = fieldValue,
|
||||
isFiatValue = isFiatValue,
|
||||
fiatValue = fiatValue,
|
||||
isValuePasted = isPastedAmount,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun updateSwapAmount(
|
||||
uiState: SwapStateHolder,
|
||||
amountFormatted: String,
|
||||
amountRaw: String,
|
||||
fieldValue: String,
|
||||
isFiatValue: Boolean,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
minTxAmount: BigDecimal?,
|
||||
isPastedAmount: Boolean,
|
||||
): SwapStateHolder {
|
||||
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
|
||||
val amountToSend = amountRaw.parseBigDecimalOrNull()
|
||||
val currency = fromSwapCurrencyStatus.currency
|
||||
val fiatRate = fromSwapCurrencyStatus.status.value.fiatRate
|
||||
val isFiatUnavailable = fiatRate == null
|
||||
val isFiatEffective = isFiatValue && !isFiatUnavailable
|
||||
val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) {
|
||||
val minAmountFormatted = minTxAmount.format {
|
||||
crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true)
|
||||
crypto(cryptoCurrency = currency, ignoreSymbolPosition = true)
|
||||
}
|
||||
(uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy(
|
||||
inputError = TransactionCardType.InputError.WrongAmount,
|
||||
|
|
@ -880,17 +1029,22 @@ internal class StateBuilder(
|
|||
accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
) ?: uiState.sendCardData.type
|
||||
}
|
||||
// The secondary line shows the opposite currency: crypto when entering fiat, fiat otherwise.
|
||||
val amountEquivalent = if (isFiatEffective) {
|
||||
stringReference(amountToSend.orZero().format { crypto(currency) })
|
||||
} else {
|
||||
getFormattedFiatAmount(fiatRate?.let { amountToSend?.multiply(it).orZero() })
|
||||
}
|
||||
return uiState.copy(
|
||||
sendCardData = uiState.sendCardData.copy(
|
||||
amountTextFieldValue = TextFieldValue(
|
||||
text = amountFormatted,
|
||||
selection = TextRange(amountFormatted.length),
|
||||
),
|
||||
amountEquivalent = getFormattedFiatAmount(
|
||||
fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate ->
|
||||
amountToSend?.multiply(fiatRate).orZero()
|
||||
},
|
||||
amountField = buildAmountField(
|
||||
amountRaw = amountRaw,
|
||||
fieldValue = fieldValue,
|
||||
isFiatValue = isFiatEffective,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
isPastedAmount = isPastedAmount,
|
||||
),
|
||||
amountEquivalent = amountEquivalent,
|
||||
type = sendInput,
|
||||
),
|
||||
)
|
||||
|
|
@ -988,7 +1142,7 @@ internal class StateBuilder(
|
|||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
|
||||
onExploreButtonClick = onExploreClick,
|
||||
navigationUM = swapSuccessNavigation(txUrl = txUrl, exploreClick = onExploreClick),
|
||||
onStatusButtonClick = onStatusClick,
|
||||
),
|
||||
)
|
||||
|
|
@ -1029,7 +1183,7 @@ internal class StateBuilder(
|
|||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status),
|
||||
onExploreButtonClick = onExploreClick,
|
||||
navigationUM = swapSuccessNavigation(txUrl = txUrl, exploreClick = onExploreClick),
|
||||
onStatusButtonClick = {},
|
||||
),
|
||||
)
|
||||
|
|
@ -1068,18 +1222,24 @@ internal class StateBuilder(
|
|||
pricesLowerBest: Map<String, Float>,
|
||||
providersStates: Map<SwapProvider, SwapState>,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
isSwapBestDexRateEnabled: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
): SwapStateHolder {
|
||||
val successStates = providersStates.getLastLoadedSuccessStates()
|
||||
val availableProvidersStates = providersStates.entries
|
||||
.mapNotNull { entry ->
|
||||
val additionalBadge = SwapProviderResolver.resolveBadge(
|
||||
states = successStates,
|
||||
provider = entry.key,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
state = entry.value,
|
||||
isSwapBestDexRateEnabled = isSwapBestDexRateEnabled,
|
||||
)
|
||||
entry.convertToProviderBottomSheetState(
|
||||
pricesLowerBest = pricesLowerBest,
|
||||
onProviderSelect = actions.onProviderSelect,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
bestRatedProviderId = bestRatedProviderId,
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
onApprovalSelectClick = actions.onApproveTypeSelect,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
}
|
||||
.sortedWith(ProviderPercentDiffComparator)
|
||||
|
|
@ -1183,24 +1343,21 @@ internal class StateBuilder(
|
|||
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
|
||||
pricesLowerBest: Map<String, Float>,
|
||||
onProviderSelect: (String) -> Unit,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
onApprovalSelectClick: (SwapProvider) -> Unit,
|
||||
additionalBadge: ProviderState.AdditionalBadge,
|
||||
): ProviderState? {
|
||||
val provider = this.key
|
||||
return when (val state = this.value) {
|
||||
val (provider, state) = this
|
||||
return when (state) {
|
||||
is SwapState.EmptyAmountState, is SwapState.Transfer -> null
|
||||
is SwapState.QuotesLoadedState -> {
|
||||
SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = state.toTokenInfo,
|
||||
permissionState = state.permissionState,
|
||||
state = state,
|
||||
pricesLowerBest = pricesLowerBest,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
isBestRate = bestRatedProviderId == provider.providerId && !state.priceImpact.shouldShowWarning(),
|
||||
isNeedBestRateBadge = isNeedBestRateBadge,
|
||||
additionalBadge = additionalBadge,
|
||||
onProviderClick = onProviderSelect,
|
||||
onApprovalSelectClick = onApprovalSelectClick,
|
||||
)
|
||||
}
|
||||
is SwapState.SwapError -> getProviderStateForError(
|
||||
|
|
@ -1209,7 +1366,7 @@ internal class StateBuilder(
|
|||
expressDataError = state.error,
|
||||
onProviderClick = onProviderSelect,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = needApplyFCARestrictions,
|
||||
additionalBadge = additionalBadge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1264,6 +1421,7 @@ internal class StateBuilder(
|
|||
return when (this) {
|
||||
is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon)
|
||||
is Account.Payment -> AccountIconUM.Payment
|
||||
is Account.Virtual -> AccountIconUM.Virtual
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
|
||||
/**
|
||||
* Adapter that exposes legacy swap [UiActions] through the shared [AmountScreenClickIntents] contract
|
||||
* so the from-card amount field can be constructed by the common `AmountFieldConverter`.
|
||||
*
|
||||
* Only the callbacks that the swap amount field actually wires are mapped to real actions; the rest
|
||||
* are no-ops, because swap-v1 overrides the corresponding [com.tangem.common.ui.amountScreen.models.AmountFieldModel]
|
||||
* fields (keyboardActions / onValuePastedTriggerDismiss) after conversion to preserve its existing behaviour.
|
||||
*/
|
||||
internal class SwapAmountScreenClickIntents(
|
||||
private val actions: UiActions,
|
||||
) : AmountScreenClickIntents {
|
||||
|
||||
override fun onAmountValueChange(value: String) = actions.onAmountChanged(value)
|
||||
|
||||
override fun onAmountPasteTriggerDismiss() = Unit
|
||||
|
||||
override fun onMaxValueClick() = actions.onMaxAmountSelected()
|
||||
|
||||
override fun onCurrencyChangeClick(isFiat: Boolean) = actions.onCurrencyChange(isFiat)
|
||||
|
||||
override fun onAmountNext() = Unit
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ private fun SwapTopBar(stateHolder: SwapStateHolder) {
|
|||
var shouldShowModeMenu by rememberSaveable { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
AppBarWithBackButtonAndIcon(
|
||||
text = stringResourceSafe(R.string.common_swap),
|
||||
text = stringResourceSafe(stateHolder.titleId),
|
||||
backIconRes = R.drawable.ic_close_24,
|
||||
iconRes = if (stateHolder.shouldShowAbMenu) R.drawable.ic_more_vertical_24 else null,
|
||||
onIconClick = if (stateHolder.shouldShowAbMenu) {
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
|
@ -50,6 +53,9 @@ 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
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private const val KEYBOARD_AUTOHIDE_DELAY_MS = 5_000L
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
|
|
@ -88,7 +94,12 @@ internal fun SwapScreenContent(
|
|||
|
||||
feeBlock?.invoke(Modifier.fillMaxWidth())
|
||||
|
||||
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
|
||||
if (state.notifications.isNotEmpty()) {
|
||||
SwapNotifications(
|
||||
notifications = state.notifications,
|
||||
onTronBannerShown = state.onTronBannerShown,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerHMax()
|
||||
|
||||
|
|
@ -140,10 +151,30 @@ internal fun SwapScreenContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
AutohideKeyboardEffect(
|
||||
sendCardState = state.sendCardData,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AutohideKeyboardEffect(sendCardState: SwapCardState) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val swapCardData = sendCardState as? SwapCardState.SwapCardData
|
||||
|
||||
LaunchedEffect(swapCardData?.amountField?.value) {
|
||||
if (swapCardData == null) return@LaunchedEffect
|
||||
|
||||
delay(KEYBOARD_AUTOHIDE_DELAY_MS)
|
||||
|
||||
focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainInfo(state: SwapStateHolder) {
|
||||
ConstraintLayout(
|
||||
|
|
@ -316,7 +347,13 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
|
|||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
private fun SwapNotifications(notifications: List<NotificationUM>) {
|
||||
private fun SwapNotifications(notifications: List<NotificationUM>, onTronBannerShown: () -> Unit) {
|
||||
// The Tron token-fee banner's show-count is an "impression": tied to actual on-screen visibility.
|
||||
// LaunchedEffect re-arms only when the boolean flips, so it fires once per hidden -> shown appearance.
|
||||
val isTronBannerShown = notifications.any { it is SwapNotificationUM.Info.TronTokenFee }
|
||||
LaunchedEffect(isTronBannerShown) {
|
||||
if (isTronBannerShown) onTronBannerShown()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(color = TangemTheme.colors.background.secondary)
|
||||
|
|
@ -413,6 +450,7 @@ private fun getButtonTitle(mode: SwapButton.Mode): String {
|
|||
// region preview
|
||||
|
||||
private val state = SwapStateHolder(
|
||||
titleId = R.string.common_swap,
|
||||
sendCardData = sendCard,
|
||||
receiveCardData = receiveCard,
|
||||
notifications = persistentListOf(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import com.tangem.common.ui.navigationButtons.NavigationButton
|
||||
import com.tangem.common.ui.navigationButtons.NavigationUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
|
||||
/**
|
||||
* Builds the [NavigationUM] for the swap success screen, mirroring
|
||||
* `SendConfirmSuccessModel.configConfirmSuccessNavigation`.
|
||||
*
|
||||
* The explore button is always the single [NavigationUM.Content.primaryButton]; a transfer also
|
||||
* gets a share button, surfaced together as [NavigationUM.Content.secondaryPairButtonsUM] (the same
|
||||
* explore + share pair the send flow shows). [shareClick] is `null` for a regular swap, so the pair
|
||||
* is absent and only the explore button is rendered.
|
||||
*/
|
||||
internal fun swapSuccessNavigation(
|
||||
txUrl: String,
|
||||
exploreClick: () -> Unit,
|
||||
shareClick: (() -> Unit)? = null,
|
||||
): NavigationUM {
|
||||
val exploreButton = NavigationButton(
|
||||
textReference = resourceReference(R.string.common_explore),
|
||||
iconRes = R.drawable.ic_web_24,
|
||||
onClick = exploreClick,
|
||||
)
|
||||
val pairButtons = shareClick
|
||||
?.takeIf { txUrl.isNotEmpty() }
|
||||
?.let { onShare ->
|
||||
exploreButton to NavigationButton(
|
||||
textReference = resourceReference(R.string.common_share),
|
||||
iconRes = R.drawable.ic_share_24,
|
||||
onClick = onShare,
|
||||
)
|
||||
}
|
||||
|
||||
return NavigationUM.Content(
|
||||
source = SOURCE,
|
||||
title = TextReference.EMPTY,
|
||||
subtitle = null,
|
||||
backIconRes = R.drawable.ic_close_24,
|
||||
backIconClick = {},
|
||||
primaryButton = exploreButton,
|
||||
secondaryPairButtonsUM = pairButtons,
|
||||
)
|
||||
}
|
||||
|
||||
private const val SOURCE = "SwapSuccess"
|
||||
|
|
@ -14,6 +14,8 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.common.ui.account.AccountTitle
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.navigationButtons.DoneButtons
|
||||
import com.tangem.common.ui.navigationButtons.NavigationUM
|
||||
import com.tangem.core.ui.components.*
|
||||
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
|
||||
|
|
@ -29,8 +31,8 @@ import com.tangem.core.ui.utils.toTimeFormat
|
|||
import com.tangem.feature.swap.models.SwapSuccessStateHolder
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.preview.SwapSuccessStatePreview
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.common.ui.FeeBlockSuccess
|
||||
|
||||
@Composable
|
||||
fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorUM?, onBack: () -> Unit) {
|
||||
|
|
@ -49,10 +51,7 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorU
|
|||
bottomBar = {
|
||||
SwapSuccessScreenButtons(
|
||||
textRes = R.string.common_close,
|
||||
txUrl = state.txUrl,
|
||||
shouldShowStatusButton = state.shouldShowStatusButton,
|
||||
onExploreClick = state.onExploreButtonClick,
|
||||
onStatusClick = state.onStatusButtonClick,
|
||||
state = state,
|
||||
onDoneClick = onBack,
|
||||
)
|
||||
},
|
||||
|
|
@ -170,40 +169,40 @@ private fun SwapAmountBlock(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun SwapSuccessScreenButtons(
|
||||
@StringRes textRes: Int,
|
||||
txUrl: String,
|
||||
shouldShowStatusButton: Boolean,
|
||||
onExploreClick: () -> Unit,
|
||||
onStatusClick: () -> Unit,
|
||||
onDoneClick: () -> Unit,
|
||||
) {
|
||||
private fun SwapSuccessScreenButtons(@StringRes textRes: Int, state: SwapSuccessStateHolder, onDoneClick: () -> Unit) {
|
||||
val content = state.navigationUM as? NavigationUM.Content
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors.background.secondary)
|
||||
.padding(TangemTheme.dimens.spacing16),
|
||||
) {
|
||||
if (txUrl.isNotBlank()) {
|
||||
Row {
|
||||
SecondaryButtonIconStart(
|
||||
text = stringResourceSafe(id = R.string.common_explore),
|
||||
iconResId = R.drawable.ic_web_24,
|
||||
onClick = onExploreClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (shouldShowStatusButton) {
|
||||
SpacerW12()
|
||||
val pairButtons = content?.secondaryPairButtonsUM
|
||||
when {
|
||||
// Transfer mode: explore + share, the same pair the send success screen shows.
|
||||
pairButtons != null -> DoneButtons(pairButtons)
|
||||
// Regular swap: single explore button (+ provider status for CEX).
|
||||
content != null && state.txUrl.isNotBlank() -> {
|
||||
Row {
|
||||
val exploreButton = content.primaryButton
|
||||
SecondaryButtonIconStart(
|
||||
text = stringResourceSafe(id = R.string.express_provider),
|
||||
iconResId = R.drawable.ic_arrow_top_right_24,
|
||||
onClick = onStatusClick,
|
||||
text = exploreButton.textReference.resolveReference(),
|
||||
iconResId = requireNotNull(exploreButton.iconRes),
|
||||
onClick = exploreButton.onClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (state.shouldShowStatusButton) {
|
||||
SpacerW12()
|
||||
SecondaryButtonIconStart(
|
||||
text = stringResourceSafe(id = R.string.express_provider),
|
||||
iconResId = R.drawable.ic_arrow_top_right_24,
|
||||
onClick = state.onStatusButtonClick,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
SpacerH12()
|
||||
}
|
||||
SpacerH12()
|
||||
}
|
||||
PrimaryButton(
|
||||
text = stringResourceSafe(id = textRes),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.feature.swap.ui
|
|||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
|
|
@ -14,13 +15,16 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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
|
||||
|
|
@ -35,9 +39,14 @@ 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.components.fields.AmountTextField
|
||||
import com.tangem.core.ui.components.fields.AmountTextFieldColors
|
||||
import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation
|
||||
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.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_16
|
||||
import com.tangem.core.ui.test.SwapTokenScreenTestTags
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.models.SwapCardState
|
||||
|
|
@ -108,9 +117,7 @@ private fun TransactionCardData(
|
|||
)
|
||||
|
||||
Content(
|
||||
type = cardState.type,
|
||||
amountEquivalent = cardState.amountEquivalent,
|
||||
textFieldValue = cardState.amountTextFieldValue,
|
||||
cardData = cardState,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
}
|
||||
|
|
@ -175,7 +182,7 @@ private fun TransactionCardEmpty(
|
|||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = cardState.amountTextFieldValue?.text.orEmpty(),
|
||||
text = cardState.amountField?.value.orEmpty(),
|
||||
color = TangemTheme.colors.text.disabled,
|
||||
style = TangemTheme.typography.h2,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
|
|
@ -326,12 +333,9 @@ private fun Header(
|
|||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun Content(
|
||||
type: TransactionCardType,
|
||||
amountEquivalent: TextReference?,
|
||||
priceImpact: PriceImpact,
|
||||
textFieldValue: TextFieldValue?,
|
||||
) {
|
||||
private fun Content(cardData: SwapCardState.SwapCardData, priceImpact: PriceImpact) {
|
||||
val type = cardData.type
|
||||
val amountEquivalent = cardData.amountEquivalent
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
|
|
@ -352,9 +356,10 @@ private fun Content(
|
|||
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
|
||||
when (type) {
|
||||
is TransactionCardType.ReadOnly -> {
|
||||
if (textFieldValue != null) {
|
||||
val value = cardData.amountField?.value
|
||||
if (value != null) {
|
||||
Text(
|
||||
text = textFieldValue.text,
|
||||
text = value,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.h2,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
|
|
@ -374,77 +379,25 @@ private fun Content(
|
|||
}
|
||||
}
|
||||
is TransactionCardType.Inputtable -> {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
AutoSizeTextField(
|
||||
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
|
||||
focusRequester = focusRequester,
|
||||
textFieldValue = textFieldValue ?: TextFieldValue(),
|
||||
isEnabled = type.isEnabled,
|
||||
onAmountChange = { type.onAmountChanged(it) },
|
||||
onFocusChange = type.onFocusChanged,
|
||||
)
|
||||
|
||||
LaunchedEffect(type.isEnabled) {
|
||||
if (type.isEnabled) {
|
||||
focusRequester.requestFocus()
|
||||
} else {
|
||||
focusRequester.freeFocus()
|
||||
}
|
||||
}
|
||||
AmountInputField(cardData = cardData, type = type, modifier = sumTextModifier)
|
||||
}
|
||||
}
|
||||
|
||||
SpacerH4()
|
||||
|
||||
if (amountEquivalent != null) {
|
||||
if (type is TransactionCardType.ReadOnly) {
|
||||
Row(
|
||||
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
|
||||
Text(
|
||||
text = amount.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
if (type.shouldShowWarning) {
|
||||
SpacerW4()
|
||||
IconButton(
|
||||
onClick = {
|
||||
type.onWarningClick?.invoke()
|
||||
},
|
||||
modifier = Modifier.size(size = TangemTheme.dimens.size20),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_information_24),
|
||||
contentDescription = null,
|
||||
tint = when (priceImpact.type) {
|
||||
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
|
||||
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
|
||||
else -> TangemTheme.colors.text.tertiary
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
|
||||
Text(
|
||||
text = amount.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
|
||||
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
when (type) {
|
||||
is TransactionCardType.ReadOnly -> ReceiveAmountEquivalent(
|
||||
amountEquivalent = amountEquivalent,
|
||||
type = type,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
is TransactionCardType.Inputtable -> SwapAmountEquivalent(
|
||||
amountEquivalent = amountEquivalent,
|
||||
isFiatValue = cardData.amountField?.isFiatValue == true,
|
||||
isFiatUnavailable = cardData.amountField?.isFiatUnavailable == true,
|
||||
onCurrencyChange = type.onCurrencyChange,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RectangleShimmer(
|
||||
|
|
@ -460,6 +413,151 @@ private fun Content(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AmountInputField(
|
||||
cardData: SwapCardState.SwapCardData,
|
||||
type: TransactionCardType.Inputtable,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val amountField = cardData.amountField ?: return
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val activeAmount = if (amountField.isFiatValue) {
|
||||
amountField.fiatAmount
|
||||
} else {
|
||||
amountField.cryptoAmount
|
||||
}
|
||||
|
||||
AmountTextField(
|
||||
value = amountField.value,
|
||||
decimals = activeAmount.decimals,
|
||||
onValueChange = amountField.onValueChange,
|
||||
textStyle = TangemTheme.typography.h2.copy(color = TangemTheme.colors.text.primary1),
|
||||
isEnabled = type.isEnabled,
|
||||
isAutoResize = true,
|
||||
visualTransformation = AmountVisualTransformation(
|
||||
currencyCode = cardData.appCurrency.code.takeIf { amountField.isFiatValue },
|
||||
symbol = activeAmount.currencySymbol.takeIf { amountField.isFiatValue },
|
||||
decimals = activeAmount.decimals,
|
||||
symbolColor = TangemTheme.colors.text.disabled,
|
||||
),
|
||||
colors = AmountTextFieldColors(
|
||||
textColor = TangemTheme.colors.text.primary1,
|
||||
disabledTextColor = TangemTheme.colors.text.disabled,
|
||||
backgroundColor = TangemTheme.colors.background.primary,
|
||||
),
|
||||
isValuePasted = amountField.isValuePasted,
|
||||
onValuePastedTriggerDismiss = amountField.onValuePastedTriggerDismiss,
|
||||
keyboardOptions = amountField.keyboardOptions,
|
||||
keyboardActions = amountField.keyboardActions,
|
||||
modifier = modifier
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { type.onFocusChanged(it.hasFocus) }
|
||||
.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
|
||||
)
|
||||
|
||||
LaunchedEffect(type.isEnabled) {
|
||||
if (type.isEnabled) {
|
||||
focusRequester.requestFocus()
|
||||
} else {
|
||||
focusRequester.freeFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReceiveAmountEquivalent(
|
||||
amountEquivalent: TextReference,
|
||||
type: TransactionCardType.ReadOnly,
|
||||
priceImpact: PriceImpact,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
|
||||
Text(
|
||||
text = amount.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
if (type.shouldShowWarning) {
|
||||
SpacerW4()
|
||||
IconButton(
|
||||
onClick = { type.onWarningClick?.invoke() },
|
||||
modifier = Modifier.size(size = TangemTheme.dimens.size20),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_information_24),
|
||||
contentDescription = null,
|
||||
tint = when (priceImpact.type) {
|
||||
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
|
||||
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
|
||||
else -> TangemTheme.colors.text.tertiary
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val CURRENCY_TOGGLE_ROTATED_DEGREE = 180f
|
||||
private const val CURRENCY_TOGGLE_INITIAL_DEGREE = 0f
|
||||
|
||||
@Composable
|
||||
private fun SwapAmountEquivalent(
|
||||
amountEquivalent: TextReference,
|
||||
isFiatValue: Boolean,
|
||||
isFiatUnavailable: Boolean,
|
||||
onCurrencyChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val rowModifier = Modifier
|
||||
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
|
||||
.then(
|
||||
if (isFiatUnavailable) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { onCurrencyChange(!isFiatValue) },
|
||||
)
|
||||
},
|
||||
)
|
||||
Row(
|
||||
modifier = rowModifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
|
||||
) {
|
||||
if (!isFiatUnavailable) {
|
||||
val iconRotation by animateFloatAsState(
|
||||
targetValue = if (isFiatValue) CURRENCY_TOGGLE_ROTATED_DEGREE else CURRENCY_TOGGLE_INITIAL_DEGREE,
|
||||
label = "Currency toggle icon rotation",
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.ic_arrow_swap_horizontal_16,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens.size16)
|
||||
.graphicsLayer { rotationZ = iconRotation },
|
||||
)
|
||||
}
|
||||
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
|
||||
Text(
|
||||
text = amount.resolveAnnotatedReference(),
|
||||
color = TangemTheme.colors.text.tertiary,
|
||||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) {
|
||||
|
|
|
|||
|
|
@ -13,14 +13,11 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
|
@ -99,8 +96,7 @@ private fun SimpleTransactionCardData(
|
|||
)
|
||||
|
||||
SimpleContent(
|
||||
type = cardState.type,
|
||||
textFieldValue = cardState.amountTextFieldValue,
|
||||
cardData = cardState,
|
||||
priceImpact = priceImpact,
|
||||
)
|
||||
}
|
||||
|
|
@ -165,7 +161,7 @@ private fun SimpleTransactionCardEmpty(
|
|||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = cardState.amountTextFieldValue?.text.orEmpty(),
|
||||
text = cardState.amountField?.value.orEmpty(),
|
||||
color = TangemTheme.colors.text.disabled,
|
||||
style = TangemTheme.typography.h2,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
|
|
@ -181,7 +177,7 @@ private fun SimpleTransactionCardEmpty(
|
|||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
|
||||
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
|
||||
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
SecondarySmallButton(
|
||||
|
|
@ -243,7 +239,7 @@ private fun SimpleTransactionCardLoading(modifier: Modifier = Modifier) {
|
|||
style = TangemTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = 20.dp, minWidth = 40.dp)
|
||||
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
|
||||
.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
|
||||
)
|
||||
}
|
||||
SecondarySmallButton(
|
||||
|
|
@ -308,7 +304,8 @@ private fun SimpleHeader(
|
|||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, textFieldValue: TextFieldValue?) {
|
||||
private fun SimpleContent(cardData: SwapCardState.SwapCardData, priceImpact: PriceImpact) {
|
||||
val type = cardData.type
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
|
|
@ -326,9 +323,10 @@ private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, t
|
|||
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
|
||||
when (type) {
|
||||
is TransactionCardType.ReadOnly -> {
|
||||
if (textFieldValue != null) {
|
||||
val value = cardData.amountField?.value
|
||||
if (value != null) {
|
||||
Text(
|
||||
text = textFieldValue.text,
|
||||
text = value,
|
||||
color = TangemTheme.colors.text.primary1,
|
||||
style = TangemTheme.typography.h2,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
|
|
@ -348,16 +346,7 @@ private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, t
|
|||
}
|
||||
}
|
||||
is TransactionCardType.Inputtable -> {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
AutoSizeTextField(
|
||||
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
|
||||
focusRequester = focusRequester,
|
||||
textFieldValue = textFieldValue ?: TextFieldValue(),
|
||||
isEnabled = type.isEnabled,
|
||||
onAmountChange = { type.onAmountChanged(it) },
|
||||
onFocusChange = type.onFocusChanged,
|
||||
)
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
AmountInputField(cardData = cardData, type = type, modifier = sumTextModifier)
|
||||
}
|
||||
}
|
||||
SpacerH4()
|
||||
|
|
|
|||
|
|
@ -1,22 +1,30 @@
|
|||
package com.tangem.feature.swap.ui.preview
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.tangem.common.ui.account.AccountNameUM
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.tokens.model.AmountType
|
||||
import com.tangem.feature.swap.models.SwapCardState
|
||||
import com.tangem.feature.swap.models.TransactionCardType
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal object SwapTransactionCardPreview {
|
||||
|
||||
val sendCard = SwapCardState.SwapCardData(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Account(
|
||||
|
|
@ -26,12 +34,33 @@ internal object SwapTransactionCardPreview {
|
|||
),
|
||||
isEnabled = true,
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = stringReference("1 000 000"),
|
||||
currencyIconState = CurrencyIconState.Loading,
|
||||
tokenSymbol = stringReference("DAI"),
|
||||
balance = stringReference("Balance: 123123123.123123 DAI"),
|
||||
isBalanceHidden = false,
|
||||
appCurrency = AppCurrency.Default,
|
||||
amountField = AmountFieldModel(
|
||||
value = "100",
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions(),
|
||||
cryptoAmount = Amount(currencySymbol = "DAI", value = BigDecimal("100"), decimals = 18),
|
||||
fiatAmount = Amount(
|
||||
currencySymbol = "$",
|
||||
value = BigDecimal("100"),
|
||||
decimals = 2,
|
||||
type = AmountType.FiatType("USD"),
|
||||
),
|
||||
isFiatValue = false,
|
||||
fiatValue = "$100.00",
|
||||
isFiatUnavailable = false,
|
||||
isValuePasted = false,
|
||||
onValuePastedTriggerDismiss = {},
|
||||
isError = false,
|
||||
isWarning = false,
|
||||
error = TextReference.EMPTY,
|
||||
),
|
||||
)
|
||||
|
||||
val receiveCard = SwapCardState.SwapCardData(
|
||||
|
|
@ -42,12 +71,33 @@ internal object SwapTransactionCardPreview {
|
|||
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
|
||||
),
|
||||
),
|
||||
amountTextFieldValue = TextFieldValue(),
|
||||
amountEquivalent = stringReference("1 000 000"),
|
||||
currencyIconState = CurrencyIconState.Loading,
|
||||
tokenSymbol = stringReference("DAI"),
|
||||
balance = stringReference("Balance: 33333 DAI"),
|
||||
isBalanceHidden = false,
|
||||
appCurrency = AppCurrency.Default,
|
||||
amountField = AmountFieldModel(
|
||||
value = "100",
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions(),
|
||||
cryptoAmount = Amount(currencySymbol = "DAI", value = BigDecimal("100"), decimals = 18),
|
||||
fiatAmount = Amount(
|
||||
currencySymbol = "$",
|
||||
value = BigDecimal("100"),
|
||||
decimals = 2,
|
||||
type = AmountType.FiatType("USD"),
|
||||
),
|
||||
isFiatValue = false,
|
||||
fiatValue = "$100.00",
|
||||
isFiatUnavailable = false,
|
||||
isValuePasted = false,
|
||||
onValuePastedTriggerDismiss = {},
|
||||
isError = false,
|
||||
isWarning = false,
|
||||
error = TextReference.EMPTY,
|
||||
),
|
||||
)
|
||||
|
||||
val emptyReadOnlyCard = SwapCardState.Empty(
|
||||
|
|
@ -55,24 +105,22 @@ internal object SwapTransactionCardPreview {
|
|||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
|
||||
),
|
||||
amountEquivalent = stringReference("$0.00"),
|
||||
amountTextFieldValue = null,
|
||||
amountField = null,
|
||||
)
|
||||
|
||||
val emptyInputtableCard = SwapCardState.Empty(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)),
|
||||
isEnabled = false,
|
||||
),
|
||||
amountEquivalent = stringReference("$0.00"),
|
||||
amountTextFieldValue = null,
|
||||
amountField = null,
|
||||
)
|
||||
|
||||
val loadingCard = SwapCardState.Loading(
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)),
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
package com.tangem.feature.swap.ui.transfer
|
||||
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedBalanceNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
|
||||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isTezos
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isTron
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -24,42 +33,57 @@ import javax.inject.Inject
|
|||
|
||||
internal class SwapTransferNotificationsFactory @Inject constructor() {
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun getNotifications(
|
||||
transferState: SwapState.Transfer,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
|
||||
onReduceToAmount: (SwapAmount) -> Unit,
|
||||
actions: UiActions,
|
||||
): ImmutableList<NotificationUM> {
|
||||
// The fee selector exposes a single sealed state; narrow it here so call sites pass the raw
|
||||
// FeeSelectorUM and this factory owns the Content/Error/Loading discrimination.
|
||||
val feeContent = feeSelectorUM
|
||||
val getFeeError = (feeSelectorUM as? FeeSelectorUM.Error)?.error
|
||||
return buildList {
|
||||
maybeAddRentExemptionError(transferState)
|
||||
addRentExemptionNotification(transferState.currencyCheck?.rentWarning)
|
||||
maybeAddDomainWarnings(
|
||||
state = transferState,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
fee = fee,
|
||||
onReduceByAmount = onReduceByAmount,
|
||||
onReduceToAmount = onReduceToAmount,
|
||||
feeSelectorUM = feeContent,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
onReduceToAmount = actions.onReduceToAmount,
|
||||
)
|
||||
maybeAddNeedReserveToCreateAccountWarning(transferState)
|
||||
maybeAddExceedsBalanceNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = feeContent,
|
||||
onBuyClick = actions.openTokenDetailsScreen,
|
||||
)
|
||||
maybeAddTooHighOrTooLowNotification(feeContent)
|
||||
addTronNetworkFeesNotification(
|
||||
cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
|
||||
transferState = transferState,
|
||||
)
|
||||
maybeAddFeeUnreachableNotification(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
feeError = getFeeError,
|
||||
actions = actions,
|
||||
)
|
||||
}.toPersistentList()
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddRentExemptionError(state: SwapState.Transfer) {
|
||||
state.currencyCheck?.rentWarning?.let {
|
||||
add(NotificationUM.Solana.RentInfo(it))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
|
||||
state: SwapState.Transfer,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
|
||||
onReduceToAmount: (SwapAmount) -> Unit,
|
||||
) {
|
||||
val swapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus
|
||||
val amount = state.fromTokenInfo.tokenAmount
|
||||
val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO
|
||||
val fee = (feeSelectorUM as? FeeSelectorUM.Content)?.selectedFeeItem?.fee
|
||||
val feeValue = fee?.amount?.value.orZero()
|
||||
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
|
||||
addExistentialWarningNotification(
|
||||
|
|
@ -97,7 +121,8 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
|
|||
sendingAmount = amount.value,
|
||||
cryptoCurrency = swapCurrencyStatus.currency,
|
||||
feeCryptoCurrency = feeCryptoCurrencyStatus?.currency,
|
||||
isAccountFunded = true,
|
||||
isAccountFunded = state.currencyCheck?.isAccountFunded == true,
|
||||
hasRequiredTrustline = state.hasRequiredTrustline,
|
||||
)
|
||||
addReduceAmountNotification(
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
|
|
@ -172,4 +197,78 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddExceedsBalanceNotifications(
|
||||
transferState: SwapState.Transfer,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
onBuyClick: (CryptoCurrency) -> Unit,
|
||||
) {
|
||||
val cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status
|
||||
addExceedsBalanceNotification(
|
||||
cryptoCurrencyWarning = transferState.cryptoCurrencyWarning,
|
||||
cryptoCurrencyStatus = cryptoCurrencyStatus,
|
||||
shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(
|
||||
networkId = cryptoCurrencyStatus.currency.network.rawId,
|
||||
),
|
||||
onClick = onBuyClick,
|
||||
onAnalyticsEvent = {},
|
||||
onResetAnalyticsEvent = {},
|
||||
)
|
||||
val feeAmount = (feeSelectorUM as? FeeSelectorUM.Content)?.selectedFeeItem?.fee?.amount?.value
|
||||
if (feeAmount != null) {
|
||||
addExceedBalanceNotification(
|
||||
feeAmount = feeAmount,
|
||||
sendingAmount = transferState.sendingAmount,
|
||||
isSubtractionAvailable = transferState.isAmountSubtractAvailable,
|
||||
cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CanBeNonNullable")
|
||||
private fun MutableList<NotificationUM>.maybeAddTooHighOrTooLowNotification(feeSelectorUM: FeeSelectorUM?) {
|
||||
val content = feeSelectorUM as? FeeSelectorUM.Content ?: return
|
||||
val (isFeeTooHigh, diff) = FeeCalculationUtils.checkIfCustomFeeTooHigh(feeSelectorUM = content)
|
||||
if (isFeeTooHigh) {
|
||||
add(NotificationUM.Warning.TooHigh(diff))
|
||||
}
|
||||
if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM = content)) {
|
||||
add(NotificationUM.Warning.FeeTooLow)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.addTronNetworkFeesNotification(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
transferState: SwapState.Transfer,
|
||||
) {
|
||||
val cryptoCurrency = cryptoCurrencyStatus.currency
|
||||
val isTronToken = cryptoCurrency is CryptoCurrency.Token && isTron(cryptoCurrency.network.rawId)
|
||||
val isVisible = isTronToken &&
|
||||
transferState.tronFeeNotificationShowCount <= TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT
|
||||
|
||||
if (isVisible) {
|
||||
add(SwapNotificationUM.Info.TronTokenFee)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddFeeUnreachableNotification(
|
||||
transferState: SwapState.Transfer,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
feeError: GetFeeError?,
|
||||
actions: UiActions,
|
||||
) {
|
||||
feeCryptoCurrencyStatus ?: return
|
||||
addFeeUnreachableNotification(
|
||||
tokenStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
|
||||
coinStatus = feeCryptoCurrencyStatus,
|
||||
feeError = feeError,
|
||||
dustValue = transferState.currencyCheck?.dustValue,
|
||||
onReload = actions.onRetryClick,
|
||||
onClick = actions.openTokenDetailsScreen,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
package com.tangem.feature.swap.ui.transfer
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
|
||||
|
|
@ -16,6 +21,7 @@ import com.tangem.core.ui.extensions.wrappedList
|
|||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.simple
|
||||
import com.tangem.core.ui.utils.parseBigDecimalOrNull
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
|
|
@ -29,8 +35,12 @@ import com.tangem.feature.swap.models.*
|
|||
import com.tangem.feature.swap.models.SwapButton.Mode
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.features.send.v2.api.utils.formatFooterFiatFee
|
||||
import com.tangem.features.send.v2.api.utils.getTronTokenFeeSendingText
|
||||
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
|
||||
import com.tangem.feature.swap.ui.swapSuccessNavigation
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.send.api.utils.formatFooterFiatFee
|
||||
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import java.math.BigDecimal
|
||||
import javax.inject.Inject
|
||||
|
|
@ -43,45 +53,35 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
|
||||
private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun createTransferState(
|
||||
actions: UiActions,
|
||||
transferState: SwapState.Transfer,
|
||||
uiStateHolder: SwapStateHolder,
|
||||
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
): SwapStateHolder {
|
||||
val fromTokenSwapInfo = transferState.fromTokenInfo
|
||||
val toTokenSwapInfo = transferState.toTokenInfo
|
||||
val isInsufficientBalance = transferState.isInsufficientBalance
|
||||
val amountTextFieldValue = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.amountTextFieldValue
|
||||
val prevSendCard = uiStateHolder.sendCardData as? SwapCardState.SwapCardData
|
||||
val prevAmountField = prevSendCard?.amountField
|
||||
val notifications = notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = feeSelectorUM,
|
||||
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
|
||||
fee = fee,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
onReduceToAmount = actions.onReduceToAmount,
|
||||
actions = actions,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
sendCardData = createSendSwapCardState(
|
||||
actions = actions,
|
||||
amountTextFieldValue = amountTextFieldValue,
|
||||
tokenSwapInfo = fromTokenSwapInfo,
|
||||
appCurrency = transferState.appCurrency,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = true,
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
),
|
||||
receiveCardData = createSendSwapCardState(
|
||||
actions = actions,
|
||||
amountTextFieldValue = amountTextFieldValue,
|
||||
tokenSwapInfo = toTokenSwapInfo,
|
||||
appCurrency = transferState.appCurrency,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
prevAmountField = prevAmountField,
|
||||
),
|
||||
receiveCardData = createReceiveCard(actions = actions, transferState = transferState),
|
||||
isInsufficientFunds = isInsufficientBalance,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(transferState.userWallet),
|
||||
|
|
@ -97,35 +97,153 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
@Suppress("LongParameterList")
|
||||
private fun createSendSwapCardState(
|
||||
actions: UiActions,
|
||||
amountTextFieldValue: TextFieldValue?,
|
||||
tokenSwapInfo: TokenSwapInfo,
|
||||
appCurrency: AppCurrency,
|
||||
isAccountsMode: Boolean,
|
||||
isFromCard: Boolean,
|
||||
isBalanceHidden: Boolean,
|
||||
isInsufficientBalance: Boolean,
|
||||
prevAmountField: AmountFieldModel?,
|
||||
): SwapCardState {
|
||||
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
|
||||
val currency = swapCurrencyStatus.currency
|
||||
|
||||
return SwapCardState.SwapCardData(
|
||||
type = createSendTransactionCardType(
|
||||
actions = actions,
|
||||
swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isFromCard = isFromCard,
|
||||
isFromCard = true,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
),
|
||||
currencyIconState = iconConverter.convert(
|
||||
value = swapCurrencyStatus.status,
|
||||
),
|
||||
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
|
||||
tokenSymbol = stringReference(currency.symbol),
|
||||
amountEquivalent = getFormattedFiatAmount(
|
||||
appCurrency = appCurrency,
|
||||
amount = tokenSwapInfo.amountFiat,
|
||||
),
|
||||
amountTextFieldValue = amountTextFieldValue,
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
appCurrency = appCurrency,
|
||||
amountField = buildAmountField(
|
||||
actions = actions,
|
||||
prevAmountField = prevAmountField,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
appCurrency = appCurrency,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the read-only receive card from [SwapState.Transfer.sendingAmount] — the amount that will
|
||||
* actually be received, already reduced by the fee when fee coverage applies. While the reduced amount
|
||||
* is not yet known (fee still loading) the amount and fiat fields are null, which makes the read-only
|
||||
* card render a shimmer instead of the un-subtracted value.
|
||||
*/
|
||||
private fun createReceiveCard(actions: UiActions, transferState: SwapState.Transfer): SwapCardState {
|
||||
val toTokenSwapInfo = transferState.toTokenInfo
|
||||
val swapCurrencyStatus = toTokenSwapInfo.swapCurrencyStatus
|
||||
val currency = swapCurrencyStatus.currency
|
||||
val appCurrency = transferState.appCurrency
|
||||
val sendingAmount = transferState.sendingAmount
|
||||
// No reduction can happen when the balance is insufficient (fee coverage requires balance >= amount),
|
||||
// so there is nothing to wait for — show the amount instead of a shimmer.
|
||||
val isLoading = transferState.isSendingAmountLoading && !transferState.isInsufficientBalance
|
||||
val fiatRate = swapCurrencyStatus.status.value.fiatRate
|
||||
|
||||
return SwapCardState.SwapCardData(
|
||||
type = createSendTransactionCardType(
|
||||
actions = actions,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
isInsufficientBalance = transferState.isInsufficientBalance,
|
||||
),
|
||||
currencyIconState = iconConverter.convert(
|
||||
value = swapCurrencyStatus.status,
|
||||
),
|
||||
tokenSymbol = stringReference(currency.symbol),
|
||||
amountEquivalent = if (isLoading) {
|
||||
null
|
||||
} else {
|
||||
getFormattedFiatAmount(appCurrency = appCurrency, amount = fiatRate?.multiply(sendingAmount))
|
||||
},
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
appCurrency = appCurrency,
|
||||
amountField = if (isLoading) {
|
||||
null
|
||||
} else {
|
||||
val value = sendingAmount.format {
|
||||
simple(decimals = currency.decimals)
|
||||
}
|
||||
displayAmountField(
|
||||
actions = actions,
|
||||
value = value,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
appCurrency = appCurrency,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the read-only receive card [AmountFieldModel] in transfer mode from a display [value].
|
||||
* The read-only UI only reads [AmountFieldModel.value].
|
||||
*/
|
||||
private fun displayAmountField(
|
||||
actions: UiActions,
|
||||
value: String,
|
||||
swapCurrencyStatus: SwapCurrencyStatus,
|
||||
appCurrency: AppCurrency,
|
||||
): AmountFieldModel = AmountFieldConverter(
|
||||
clickIntents = SwapAmountScreenClickIntents(actions),
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
appCurrency = appCurrency,
|
||||
).convert(value = "").copy(
|
||||
value = value,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
keyboardActions = KeyboardActions(),
|
||||
onValuePastedTriggerDismiss = {},
|
||||
)
|
||||
|
||||
/**
|
||||
* Rebuilds the "from" card [AmountFieldModel] in transfer mode, preserving the previously entered
|
||||
* value and the crypto/fiat toggle while refreshing currency-derived fields against the latest status.
|
||||
*/
|
||||
private fun buildAmountField(
|
||||
actions: UiActions,
|
||||
prevAmountField: AmountFieldModel?,
|
||||
swapCurrencyStatus: SwapCurrencyStatus,
|
||||
appCurrency: AppCurrency,
|
||||
): AmountFieldModel {
|
||||
val fiatRate = swapCurrencyStatus.status.value.fiatRate
|
||||
val isFiatValue = prevAmountField?.isFiatValue == true && fiatRate != null
|
||||
val cryptoDecimal = prevAmountField?.cryptoAmount?.value.orZero()
|
||||
val fiatDecimal = fiatRate?.multiply(cryptoDecimal)
|
||||
// The converter is the single source for cryptoAmount / fiatAmount construction (FIAT_DECIMALS = 2).
|
||||
// The previously entered value + crypto/fiat toggle display are restored afterwards via copy(...),
|
||||
// keeping the resulting AmountFieldModel field-for-field equivalent to the prior hand-rolled builder.
|
||||
return AmountFieldConverter(
|
||||
clickIntents = SwapAmountScreenClickIntents(actions),
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
appCurrency = appCurrency,
|
||||
).convert(value = cryptoDecimal.toPlainString()).copy(
|
||||
value = prevAmountField?.value.orEmpty(),
|
||||
isFiatValue = isFiatValue,
|
||||
fiatValue = fiatDecimal?.format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}.orEmpty(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
keyboardActions = KeyboardActions(),
|
||||
onValuePastedTriggerDismiss = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +265,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
)
|
||||
}
|
||||
TransactionCardType.Inputtable(
|
||||
onAmountChanged = actions.onAmountChanged,
|
||||
onCurrencyChange = actions.onCurrencyChange,
|
||||
onFocusChanged = actions.onAmountSelected,
|
||||
inputError = if (isInsufficientBalance) {
|
||||
TransactionCardType.InputError.InsufficientFunds
|
||||
|
|
@ -208,9 +326,13 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
return when (this) {
|
||||
is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon)
|
||||
is Account.Payment -> AccountIconUM.Payment
|
||||
is Account.Virtual -> AccountIconUM.Virtual
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [isTangemPayWithdrawal] - if true - Tangem pay withdrawal done with no fee, skip fee nullability check
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun updateTransferButtonEnableState(
|
||||
dataState: SwapProcessDataState,
|
||||
|
|
@ -219,30 +341,41 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
uiStateHolder: SwapStateHolder,
|
||||
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
feeSelectorUM: FeeSelectorUM?,
|
||||
): SwapStateHolder {
|
||||
val notifications = notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
|
||||
fee = fee,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
onReduceToAmount = actions.onReduceToAmount,
|
||||
feeSelectorUM = feeSelectorUM,
|
||||
actions = actions,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
notifications = notifications,
|
||||
// Rebuild the receive card from the refreshed transferState: this path runs after the fee
|
||||
// selector resolves, when sendingAmount may have just been reduced by the fee. Only the
|
||||
// receive card is rebuilt to avoid clobbering the user's in-progress input on the "from" card.
|
||||
receiveCardData = createReceiveCard(actions = actions, transferState = transferState),
|
||||
swapButton = uiStateHolder.swapButton.copy(
|
||||
isEnabled = getTransferButtonEnabled(notifications, fee),
|
||||
isEnabled = getTransferButtonEnabled(notifications, fee, isTangemPayWithdrawal),
|
||||
),
|
||||
transferFooter = getSendingFooterText(
|
||||
dataState = dataState,
|
||||
fee = fee,
|
||||
tokenSwapInfo = transferState.fromTokenInfo,
|
||||
appCurrency = transferState.appCurrency,
|
||||
isFeeSubtractedFromAmount = isFeeSubtractedFromAmount(transferState, fee),
|
||||
isFeeExceedingBalance = isFeeExceedingBalance(transferState, fee),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getTransferButtonEnabled(notifications: ImmutableList<NotificationUM>, fee: Fee?): Boolean {
|
||||
return fee != null && notifications.none { notification ->
|
||||
private fun getTransferButtonEnabled(
|
||||
notifications: ImmutableList<NotificationUM>,
|
||||
fee: Fee?,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
): Boolean {
|
||||
return (fee != null || isTangemPayWithdrawal) && notifications.none { notification ->
|
||||
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
|
||||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
|
||||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
|
||||
|
|
@ -253,23 +386,50 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun isFeeSubtractedFromAmount(transferState: SwapState.Transfer, fee: Fee?): Boolean {
|
||||
if (!transferState.isAmountSubtractAvailable || fee == null) return false
|
||||
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
|
||||
val balance = swapCurrencyStatus.status.value.amount.orZero()
|
||||
val amountValue = transferState.fromTokenInfo.tokenAmount.value
|
||||
return amountValue + fee.amount.value.orZero() > balance
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the fee alone exceeds the balance: nothing can be sent (not even enough to cover the fee), so
|
||||
* the footer must show $0 instead of a positive total. Only meaningful when the fee is paid from the same
|
||||
* balance (subtraction available).
|
||||
*/
|
||||
private fun isFeeExceedingBalance(transferState: SwapState.Transfer, fee: Fee?): Boolean {
|
||||
if (!transferState.isAmountSubtractAvailable || fee == null) return false
|
||||
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
|
||||
val balance = swapCurrencyStatus.status.value.amount.orZero()
|
||||
return fee.amount.value.orZero() > balance
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun getSendingFooterText(
|
||||
dataState: SwapProcessDataState,
|
||||
fee: Fee?,
|
||||
tokenSwapInfo: TokenSwapInfo,
|
||||
appCurrency: AppCurrency,
|
||||
isFeeSubtractedFromAmount: Boolean,
|
||||
isFeeExceedingBalance: Boolean,
|
||||
): TextReference? {
|
||||
if (fee == null) return null
|
||||
|
||||
val fiatAmountValue = tokenSwapInfo.amountFiat
|
||||
val status = dataState.fromSwapCurrencyStatus?.status ?: return null
|
||||
val fiatFeeValue = fee.amount.value
|
||||
val value = dataState.feePaidCryptoCurrency?.value
|
||||
val fiatFeeValue = value?.fiatRate?.multiply(fee.amount.value)
|
||||
val isFeeConvertibleToFiat = status.currency.network.hasFiatFeeRate
|
||||
|
||||
val fiatSendingValue = if (isFeeConvertibleToFiat) {
|
||||
fiatFeeValue?.let { fiatAmountValue.plus(it) }
|
||||
} else {
|
||||
fiatAmountValue
|
||||
val fiatSendingValue = when {
|
||||
!isFeeConvertibleToFiat -> fiatAmountValue
|
||||
// Fee alone exceeds the balance → the transaction can't go through, nothing is sent.
|
||||
isFeeExceedingBalance -> BigDecimal.ZERO
|
||||
// Fee is taken out of the entered amount → it already includes the fee, don't add it again.
|
||||
isFeeSubtractedFromAmount -> fiatAmountValue
|
||||
else -> fiatFeeValue?.let { fiatAmountValue.plus(it) }
|
||||
}
|
||||
|
||||
val fiatSending = fiatSendingValue.format {
|
||||
|
|
@ -280,8 +440,11 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
}
|
||||
|
||||
val networkId = status.currency.network.id
|
||||
// When the fee is convertible to fiat, show the fiat-converted value; otherwise keep the raw
|
||||
// crypto fee amount — formatFooterFiatFee renders amount.value as crypto in the non-fiat case.
|
||||
val feeAmount = if (isFeeConvertibleToFiat) fee.amount.copy(value = fiatFeeValue) else fee.amount
|
||||
val fiatFee = formatFooterFiatFee(
|
||||
amount = fee.amount.copy(value = fiatFeeValue),
|
||||
amount = feeAmount,
|
||||
isFeeConvertibleToFiat = isFeeConvertibleToFiat,
|
||||
isFeeApproximate = isFeeApproximateUseCase(networkId = networkId, amountType = fee.amount.type),
|
||||
appCurrency = appCurrency,
|
||||
|
|
@ -296,9 +459,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
} else {
|
||||
resourceReference(
|
||||
id = if (isFeeConvertibleToFiat) {
|
||||
com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description
|
||||
com.tangem.features.send.impl.R.string.send_summary_transaction_description
|
||||
} else {
|
||||
com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description_no_fiat_fee
|
||||
com.tangem.features.send.impl.R.string.send_summary_transaction_description_no_fiat_fee
|
||||
},
|
||||
formatArgs = wrappedList(fiatSending, fiatFee),
|
||||
)
|
||||
|
|
@ -318,13 +481,13 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
fun createSuccessState(
|
||||
uiState: SwapStateHolder,
|
||||
dataState: SwapProcessDataState,
|
||||
appCurrency: AppCurrency,
|
||||
isAccountsMode: Boolean,
|
||||
fee: Fee?,
|
||||
txUrl: String,
|
||||
timestamp: Long,
|
||||
fee: TextReference?,
|
||||
onExplorerClick: () -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
): SwapStateHolder {
|
||||
val transferState = requireNotNull(dataState.currentTransferState)
|
||||
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
|
||||
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
|
||||
val amount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
|
|
@ -332,14 +495,14 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
val fromCurrency = fromSwapCurrencyStatus.currency
|
||||
val toCurrency = toSwapCurrencyStatus.currency
|
||||
val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) }
|
||||
val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
|
||||
val toAmountText = transferState.sendingAmount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
|
||||
val fromFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = appCurrency,
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
|
||||
)
|
||||
val toFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = appCurrency,
|
||||
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(transferState.sendingAmount),
|
||||
)
|
||||
|
||||
return uiState.copy(
|
||||
|
|
@ -352,15 +515,15 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
isTransferMode = true,
|
||||
providerIcon = "",
|
||||
rate = TextReference.EMPTY,
|
||||
fee = fee,
|
||||
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
|
||||
fromTitle = getCardAccountTitle(
|
||||
account = fromSwapCurrencyStatus.account,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = true,
|
||||
),
|
||||
toTitle = getCardAccountTitle(
|
||||
account = toSwapCurrencyStatus.account,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
),
|
||||
fromTokenAmount = stringReference(fromAmountText),
|
||||
|
|
@ -369,9 +532,90 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
|
||||
onExploreButtonClick = onExplorerClick,
|
||||
navigationUM = swapSuccessNavigation(
|
||||
txUrl = txUrl,
|
||||
exploreClick = onExplorerClick,
|
||||
shareClick = onShareClick,
|
||||
),
|
||||
onStatusButtonClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createTangemPayWithdrawalSuccessState(
|
||||
uiState: SwapStateHolder,
|
||||
dataState: SwapProcessDataState,
|
||||
fee: Fee?,
|
||||
onExploreClick: () -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
): SwapStateHolder {
|
||||
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
|
||||
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
|
||||
val transferState = requireNotNull(dataState.currentTransferState)
|
||||
val fromAmount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
val toAmount = transferState.sendingAmount
|
||||
val fromFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount),
|
||||
)
|
||||
val toFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount),
|
||||
)
|
||||
|
||||
return uiState.copy(
|
||||
successState = SwapSuccessStateHolder(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
txUrl = "",
|
||||
providerName = stringReference(""),
|
||||
providerType = stringReference(""),
|
||||
shouldShowStatusButton = false,
|
||||
isTransferMode = true,
|
||||
providerIcon = "",
|
||||
rate = TextReference.EMPTY,
|
||||
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
|
||||
fromTitle = getCardAccountTitle(
|
||||
account = fromSwapCurrencyStatus.account,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = true,
|
||||
),
|
||||
toTitle = getCardAccountTitle(
|
||||
account = toSwapCurrencyStatus.account,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
),
|
||||
fromTokenAmount = stringReference(fromAmount.toString()),
|
||||
toTokenAmount = stringReference(toAmount.toString()),
|
||||
fromTokenFiatAmount = fromFiatAmount,
|
||||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
|
||||
navigationUM = swapSuccessNavigation(
|
||||
txUrl = "",
|
||||
exploreClick = onExploreClick,
|
||||
shareClick = onShareClick,
|
||||
),
|
||||
onStatusButtonClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatFeeForSuccess(transferState: SwapState.Transfer, fee: Fee): TextReference {
|
||||
val feeAmount = fee.amount
|
||||
val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO
|
||||
val cryptoFormatted = totalFeeValue.format {
|
||||
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
|
||||
}
|
||||
val appCurrency = transferState.appCurrency
|
||||
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
|
||||
val fiatRate = swapCurrencyStatus.status.value.fiatRate
|
||||
val fiatFormatted = fiatRate?.multiply(totalFeeValue).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
return stringReference("$cryptoFormatted ($fiatFormatted)")
|
||||
}
|
||||
|
||||
fun updateTransferTitle(uiState: SwapStateHolder): SwapStateHolder {
|
||||
return uiState.copy(titleId = R.string.common_transfer)
|
||||
}
|
||||
}
|
||||
|
|
@ -1151,6 +1151,161 @@ internal class DefaultInitialCurrenciesResolverTest {
|
|||
|
||||
// endregion
|
||||
|
||||
// region explicit TO currency
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit TO currency present in portfolio WHEN invoke with position FROM THEN it is placed as TO`() =
|
||||
runTest {
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||
|
||||
// Same token (network + contract), different id instance from the passed one.
|
||||
val accountTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
|
||||
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||
val toStatus = createCurrencyStatus(accountTo, fiatAmount = BigDecimal("50"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus, toStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountFrom to true, accountTo to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialFrom,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(toStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit TO currency not present in portfolio WHEN invoke THEN TO stays null`() = runTest {
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xNOT_IN_WALLET"))
|
||||
|
||||
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountFrom to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialFrom,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit TO currency is the same token as FROM WHEN invoke THEN TO stays null`() = runTest {
|
||||
val sharedId = mockCurrencyId("ethereum", "0xUSDT")
|
||||
val initialFrom = mockCryptoCurrency(id = sharedId)
|
||||
val accountFrom = mockCryptoCurrency(id = sharedId)
|
||||
// Passed TO is the same token as FROM (different id instance, same network + contract).
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
|
||||
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(fromStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountFrom to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialFrom,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||
assertThat(to).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN TO slot already filled by position TO WHEN explicit TO provided THEN explicit TO is ignored`() =
|
||||
runTest {
|
||||
val sharedId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialCurrency = mockCryptoCurrency(id = sharedId)
|
||||
val accountCurrency = mockCryptoCurrency(id = sharedId)
|
||||
|
||||
// A different token that exists in the portfolio and would match the explicit TO.
|
||||
val otherTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
|
||||
val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100"))
|
||||
val otherStatus = createCurrencyStatus(otherTo, fiatAmount = BigDecimal("50"))
|
||||
val accountStatus = createCryptoPortfolioAccountStatus(listOf(status, otherStatus))
|
||||
setupSupplier(listOf(accountStatus))
|
||||
setupAvailability(linkedMapOf(accountCurrency to true, otherTo to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialCurrency,
|
||||
swapCurrencyPosition = CurrencyPosition.TO,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
// Position TO already placed the selected currency in TO; explicit TO must not override it.
|
||||
assertThat(from).isNull()
|
||||
assertThat(to?.status).isSameInstanceAs(status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit TO currency exists in multiple accounts WHEN invoke THEN instance from FROM account is used`() =
|
||||
runTest {
|
||||
// FROM lives in account 1.
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val initialFrom = mockCryptoCurrency(id = fromId)
|
||||
val accountFrom = mockCryptoCurrency(id = fromId)
|
||||
|
||||
// Same TO token present in both accounts (different id instances).
|
||||
val toInAccount1 = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
val toInAccount2 = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
val explicitTo = mockCryptoCurrency(id = mockCurrencyId("ethereum", "0xUSDT"))
|
||||
|
||||
val fromStatus = createCurrencyStatus(accountFrom, fiatAmount = BigDecimal("100"))
|
||||
val toInAccount1Status = createCurrencyStatus(toInAccount1, fiatAmount = BigDecimal("10"))
|
||||
val toInAccount2Status = createCurrencyStatus(toInAccount2, fiatAmount = BigDecimal("5000"))
|
||||
|
||||
val account1 = createCryptoPortfolioAccountStatus(
|
||||
currencies = listOf(fromStatus, toInAccount1Status),
|
||||
derivationIndexValue = 0,
|
||||
)
|
||||
val account2 = createCryptoPortfolioAccountStatus(
|
||||
currencies = listOf(toInAccount2Status),
|
||||
derivationIndexValue = 1,
|
||||
)
|
||||
setupSupplier(listOf(account1, account2))
|
||||
setupAvailability(linkedMapOf(accountFrom to true, toInAccount1 to true))
|
||||
setupAvailability(linkedMapOf(toInAccount2 to true))
|
||||
|
||||
val (from, to) = resolver.invoke(
|
||||
userWalletId,
|
||||
initialCryptoCurrency = initialFrom,
|
||||
swapCurrencyPosition = CurrencyPosition.FROM,
|
||||
isPaymentAccount = false,
|
||||
initialToCryptoCurrency = explicitTo,
|
||||
)
|
||||
|
||||
// TO must be the instance from the FROM account, not the higher-balance duplicate in account 2.
|
||||
assertThat(from?.status).isSameInstanceAs(fromStatus)
|
||||
assertThat(to?.status).isSameInstanceAs(toInAccount1Status)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region helpers
|
||||
|
||||
private fun mockCryptoCurrency(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.tangem.domain.express.models.ExpressError
|
|||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
|
|
@ -270,6 +271,50 @@ internal class StateBuilderInitialStateTest {
|
|||
|
||||
assertThat(result.notifications).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same coin on different wallets in transfer mode WHEN called THEN shouldShowMaxAmount is true`() {
|
||||
// Arrange — same coin (shared network id) moved between two wallets => transfer mode
|
||||
val sharedNetwork = buildSharedNetwork()
|
||||
val walletA: UserWallet.Cold = mockk(relaxed = true) { every { walletId } returns UserWalletId("aabb") }
|
||||
val walletB: UserWallet.Cold = mockk(relaxed = true) { every { walletId } returns UserWalletId("ccdd") }
|
||||
val fromStatus = buildCoinSwapCurrencyStatus(walletA, sharedNetwork)
|
||||
val toStatus = buildCoinSwapCurrencyStatus(walletB, sharedNetwork)
|
||||
val transferState = SwapState.EmptyAmountState(
|
||||
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
|
||||
isTransferMode = true,
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = sut.createInitialReadyState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = transferState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.shouldShowMaxAmount).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same-network coin swap not in transfer mode WHEN called THEN shouldShowMaxAmount is false`() {
|
||||
// Arrange — same network coin pair, regular swap => MAX hidden to keep balance for the fee
|
||||
val sharedNetwork = buildSharedNetwork()
|
||||
val fromStatus = buildCoinSwapCurrencyStatus(userWallet, sharedNetwork)
|
||||
val toStatus = buildCoinSwapCurrencyStatus(userWallet, sharedNetwork)
|
||||
|
||||
// Act
|
||||
val result = sut.createInitialReadyState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.shouldShowMaxAmount).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -574,4 +619,43 @@ internal fun buildSwapCurrencyStatus(
|
|||
status = cryptoCurrencyStatus,
|
||||
account = account,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single [Network] mock whose [Network.id] resolves to one shared instance, so two currencies built from
|
||||
* it compare equal on `network.id` — the condition that gates [StateBuilder.shouldShowMaxAmount].
|
||||
*/
|
||||
internal fun buildSharedNetwork(): Network = mockk(relaxed = true) {
|
||||
every { id } returns mockk(relaxed = true)
|
||||
every { name } returns "Ethereum"
|
||||
every { currencySymbol } returns "ETH"
|
||||
every { rawId } returns "ethereum"
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [SwapCurrencyStatus] backed by a [CryptoCurrency.Coin] on the given [network]. Pass the same
|
||||
* [network] instance to two calls to model the "same coin on different wallets" (transfer) case.
|
||||
*/
|
||||
internal fun buildCoinSwapCurrencyStatus(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): SwapCurrencyStatus {
|
||||
val account = Account.CryptoPortfolio.createMainAccount(userWallet.walletId)
|
||||
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { symbol } returns "ETH"
|
||||
every { decimals } returns 18
|
||||
every { name } returns "Ethereum"
|
||||
every { this@mockk.network } returns network
|
||||
}
|
||||
val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) {
|
||||
every { amount } returns java.math.BigDecimal("1.0")
|
||||
every { fiatRate } returns java.math.BigDecimal("2000.00")
|
||||
every { fiatAmount } returns java.math.BigDecimal("2000.00")
|
||||
}
|
||||
val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = statusValue)
|
||||
return SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
status = cryptoCurrencyStatus,
|
||||
account = account,
|
||||
)
|
||||
}
|
||||
|
|
@ -23,8 +23,8 @@ import com.tangem.feature.swap.domain.models.SwapAmount
|
|||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.ui.StateBuilder
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
import io.mockk.every
|
||||
|
|
@ -106,9 +106,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
)
|
||||
|
|
@ -133,9 +131,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
)
|
||||
|
|
@ -164,9 +160,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = null,
|
||||
feeError = null,
|
||||
)
|
||||
|
|
@ -190,9 +184,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = buildSwapFee(),
|
||||
feeError = null,
|
||||
)
|
||||
|
|
@ -220,9 +212,7 @@ internal class StateBuilderSwapButtonTest {
|
|||
quoteModel = state,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = buildProvider(ExchangeProviderType.CEX),
|
||||
bestRatedProviderId = "p",
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
swapFee = buildSwapFee(),
|
||||
feeError = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.models.SwapCardState
|
||||
import com.tangem.feature.swap.models.SwapStateHolder
|
||||
import com.tangem.feature.swap.models.TransactionCardType
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.ui.StateBuilder
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
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 StateBuilderUpdateSwapAmountTest {
|
||||
|
||||
private val actions: UiActions = mockk(relaxed = true)
|
||||
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
|
||||
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
|
||||
private val isAccountsModeProvider: Provider<Boolean> = mockk()
|
||||
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
|
||||
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
|
||||
private val appRouter: AppRouter = mockk()
|
||||
|
||||
private val appCurrency = AppCurrency.Default
|
||||
|
||||
private val userWalletId = UserWalletId("aabbccdd")
|
||||
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
private lateinit var sut: StateBuilder
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { isBalanceHiddenProvider() } returns false
|
||||
every { appCurrencyProvider() } returns appCurrency
|
||||
every { isAccountsModeProvider() } returns false
|
||||
|
||||
sut = StateBuilder(
|
||||
actions = actions,
|
||||
isBalanceHiddenProvider = isBalanceHiddenProvider,
|
||||
appCurrencyProvider = appCurrencyProvider,
|
||||
isAccountsModeProvider = isAccountsModeProvider,
|
||||
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
|
||||
swapFeatureToggles = swapFeatureToggles,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readyState(fromStatus: SwapCurrencyStatus): SwapStateHolder = sut.createInitialReadyState(
|
||||
uiStateHolder = sut.createInitialLoadingState(),
|
||||
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = stringReference("$0.00")),
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = buildSwapCurrencyStatus(coldWallet),
|
||||
)
|
||||
|
||||
private val SwapStateHolder.sendCard: SwapCardState.SwapCardData
|
||||
get() = sendCardData as SwapCardState.SwapCardData
|
||||
|
||||
@Test
|
||||
fun `GIVEN crypto input WHEN updateSwapAmount THEN field shows crypto value and equivalent is fiat`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet) // fiatRate = 2000
|
||||
val base = readyState(fromStatus)
|
||||
|
||||
// Act
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = base,
|
||||
amountRaw = "0.5",
|
||||
fieldValue = "0.5",
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val field = result.sendCard.amountField!!
|
||||
assertThat(field.value).isEqualTo("0.5")
|
||||
assertThat(field.isFiatValue).isFalse()
|
||||
assertThat(field.cryptoAmount.value).isEqualTo(BigDecimal("0.5"))
|
||||
assertThat(field.isValuePasted).isFalse()
|
||||
// 0.5 * 2000 = 1000 fiat
|
||||
assertThat(result.sendCard.amountEquivalent).isEqualTo(
|
||||
stringReference(
|
||||
BigDecimal("1000.00").format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fiat input WHEN updateSwapAmount THEN field marked fiat and equivalent is crypto`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet) // fiatRate = 2000
|
||||
val base = readyState(fromStatus)
|
||||
|
||||
// Act
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = base,
|
||||
amountRaw = "0.5", // crypto authoritative amount
|
||||
fieldValue = "1000", // displayed fiat value
|
||||
isFiatValue = true,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val field = result.sendCard.amountField!!
|
||||
assertThat(field.value).isEqualTo("1000")
|
||||
assertThat(field.isFiatValue).isTrue()
|
||||
assertThat(field.cryptoAmount.value).isEqualTo(BigDecimal("0.5"))
|
||||
// equivalent line shows the crypto amount when entering fiat
|
||||
assertThat(result.sendCard.amountEquivalent).isEqualTo(
|
||||
stringReference(BigDecimal("0.5").format { crypto(fromStatus.currency) }),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fiat input but fiat rate unavailable WHEN updateSwapAmount THEN field falls back to crypto display`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatusNoFiatRate(coldWallet)
|
||||
val base = readyState(buildSwapCurrencyStatus(coldWallet))
|
||||
|
||||
// Act
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = base,
|
||||
amountRaw = "0.5",
|
||||
fieldValue = "0.5",
|
||||
isFiatValue = true,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val field = result.sendCard.amountField!!
|
||||
// isFiatValue collapses to false because the rate is unavailable
|
||||
assertThat(field.isFiatValue).isFalse()
|
||||
assertThat(field.isFiatUnavailable).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN amount below min WHEN updateSwapAmount THEN send card reports WrongAmount error`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val base = readyState(fromStatus)
|
||||
|
||||
// Act
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = base,
|
||||
amountRaw = "0.5",
|
||||
fieldValue = "0.5",
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = BigDecimal("1"),
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val inputtable = result.sendCard.type as TransactionCardType.Inputtable
|
||||
assertThat(inputtable.inputError).isEqualTo(TransactionCardType.InputError.WrongAmount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN amount at or above min WHEN updateSwapAmount THEN send card has no input error`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val base = readyState(fromStatus)
|
||||
|
||||
// Act
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = base,
|
||||
amountRaw = "2",
|
||||
fieldValue = "2",
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = BigDecimal("1"),
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val inputtable = result.sendCard.type as TransactionCardType.Inputtable
|
||||
assertThat(inputtable.inputError).isEqualTo(TransactionCardType.InputError.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pasted amount WHEN updateSwapAmount THEN field flags value as pasted`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val base = readyState(fromStatus)
|
||||
|
||||
// Act
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = base,
|
||||
amountRaw = "0.5",
|
||||
fieldValue = "0.5",
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
isPastedAmount = true,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.sendCard.amountField!!.isValuePasted).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN send card is not SwapCardData WHEN updateSwapAmount THEN uiState returned unchanged`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val loadingState = sut.createInitialLoadingState() // send card is Empty, not SwapCardData
|
||||
|
||||
// Act
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = loadingState,
|
||||
amountRaw = "0.5",
|
||||
fieldValue = "0.5",
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isSameInstanceAs(loadingState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN ready state WHEN createQuotesEmptyAmountState THEN receive amount resets to zero and button disabled`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val base = sut.updateSwapAmount(
|
||||
uiState = readyState(fromStatus),
|
||||
amountRaw = "0.5",
|
||||
fieldValue = "0.5",
|
||||
isFiatValue = false,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
isPastedAmount = false,
|
||||
)
|
||||
val zeroEquivalent = stringReference("$0.00")
|
||||
|
||||
// Act
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = base,
|
||||
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = zeroEquivalent),
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
assertThat(receiveCard.amountField!!.value).isEqualTo("0")
|
||||
assertThat(result.sendCard.amountEquivalent).isEqualTo(zeroEquivalent)
|
||||
assertThat(receiveCard.amountEquivalent).isEqualTo(zeroEquivalent)
|
||||
assertThat(result.swapButton.isEnabled).isFalse()
|
||||
assertThat(result.isInsufficientFunds).isFalse()
|
||||
assertThat(result.notifications).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN receive card is not SwapCardData WHEN createQuotesEmptyAmountState THEN uiState returned unchanged`() {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
// createInitialReadyState builds SwapCardData send + SwapCardData receive, but loading state has Empty cards
|
||||
val loadingState = sut.createInitialLoadingState()
|
||||
|
||||
// Act
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = loadingState,
|
||||
emptyAmountState = SwapState.EmptyAmountState(zeroAmountEquivalent = stringReference("$0.00")),
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isSameInstanceAs(loadingState)
|
||||
}
|
||||
|
||||
private fun buildSwapCurrencyStatusNoFiatRate(userWallet: UserWallet): SwapCurrencyStatus {
|
||||
val status = buildSwapCurrencyStatus(userWallet)
|
||||
every { status.status.value.fiatRate } returns null
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,523 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Tests for [SwapProviderResolver] — best-provider selection ([SwapProviderResolver.findBest]) and
|
||||
* row-badge resolution ([SwapProviderResolver.resolveBadge]).
|
||||
*
|
||||
* Ranking metric: best provider == lowest `from/to` fiat ratio == highest `to` fiat output for the
|
||||
* same `from` input. "Best DEX Rate" prefers the best-rated DEX/DEX_BRIDGE provider when the feature
|
||||
* is on and any DEX is present.
|
||||
*/
|
||||
internal class SwapProviderResolverTest {
|
||||
|
||||
private val cex1 = provider(id = "cex1", type = ExchangeProviderType.CEX)
|
||||
private val cex2 = provider(id = "cex2", type = ExchangeProviderType.CEX)
|
||||
private val dex1 = provider(id = "dex1", type = ExchangeProviderType.DEX)
|
||||
private val dexBridge = provider(id = "dexBridge", type = ExchangeProviderType.DEX_BRIDGE)
|
||||
|
||||
/** Mirror of the provider ids the resolver treats as FCA restricted. */
|
||||
private val fcaRestrictedProviderIds = setOf(
|
||||
"changelly",
|
||||
"changenow",
|
||||
"okx-cross-chain",
|
||||
"okx-on-chain",
|
||||
"simpleswap",
|
||||
)
|
||||
|
||||
// region findBest
|
||||
|
||||
@Test
|
||||
fun `GIVEN best dex rate on AND a DEX present WHEN findBest THEN best DEX is selected`() {
|
||||
// CEX has the best overall rate (highest output), but a DEX is present.
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"), // best among DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "105"),
|
||||
)
|
||||
|
||||
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
|
||||
|
||||
assertThat(best).isEqualTo(dex1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best dex rate on AND no DEX present WHEN findBest THEN best CEX fallback`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "120"), // best CEX
|
||||
)
|
||||
|
||||
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
|
||||
|
||||
assertThat(best).isEqualTo(cex2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best dex rate off WHEN findBest THEN best overall regardless of type`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = false)
|
||||
|
||||
assertThat(best).isEqualTo(cex1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX_BRIDGE is the best DEX WHEN findBest with best dex rate on THEN DEX_BRIDGE selected`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "130"),
|
||||
dex1 to quote(fromFiat = "100", toFiat = "108"),
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "115"), // best among DEX-based
|
||||
)
|
||||
|
||||
val best = SwapProviderResolver.findBest(states, isSwapBestDexRateEnabled = true)
|
||||
|
||||
assertThat(best).isEqualTo(dexBridge)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region resolveBadge
|
||||
|
||||
// --- Both CEX + DEX present, a DEX is the overall best → only "Best rate" (BestTrade) on that DEX.
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND DEX is overall best WHEN resolveBadge for that DEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
dex1 to quote(fromFiat = "100", toFiat = "120"), // best overall AND best DEX
|
||||
cex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
// Overall best is the DEX → it gets the single "Best rate" badge, NOT "Best DEX rate".
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND DEX is overall best WHEN resolveBadge for the CEX THEN Empty`() {
|
||||
val states = mapOf(
|
||||
dex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
cex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// --- Both CEX + DEX present, a CEX is the overall best → BestTrade on the CEX, BestDexRate on the best DEX.
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for the CEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "105"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for the best DEX THEN BestDexRate`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall (a CEX)
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "105"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
// CEX wins overall, so the best DEX additionally gets the "Best DEX rate" badge.
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestDexRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN both types present AND CEX is overall best WHEN resolveBadge for a non-best DEX THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "105"), // not the best DEX
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dexBridge,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dexBridge),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// --- DEX-only → only "Best rate" (BestTrade) on the best DEX; no separate "Best DEX rate".
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX-only providers WHEN resolveBadge for the best DEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
dex1 to quote(fromFiat = "100", toFiat = "120"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX-only providers WHEN resolveBadge for a non-best DEX THEN Empty`() {
|
||||
val states = mapOf(
|
||||
dex1 to quote(fromFiat = "100", toFiat = "120"), // best DEX
|
||||
dexBridge to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dexBridge,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dexBridge),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// --- CEX-only → only "Best rate" (BestTrade) on the best CEX (no DEX exists).
|
||||
|
||||
@Test
|
||||
fun `GIVEN CEX-only providers WHEN resolveBadge for the best CEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best CEX
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
// --- Toggle off → only the overall best gets BestTrade; "Best DEX rate" is never produced.
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle off AND both types present with CEX best WHEN resolveBadge for the CEX THEN BestTrade`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = false,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle off AND both types present with CEX best WHEN resolveBadge for the DEX THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best overall
|
||||
dex1 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = dex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(dex1),
|
||||
isSwapBestDexRateEnabled = false,
|
||||
)
|
||||
|
||||
// Toggle off → no "Best DEX rate" badge even though a DEX is present and not the overall best.
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted provider AND restrictions on WHEN resolveBadge THEN FCAWarningList`() {
|
||||
val restricted = provider(id = "changelly", type = ExchangeProviderType.CEX, isRecommended = true)
|
||||
val states = mapOf(
|
||||
restricted to quote(fromFiat = "100", toFiat = "120"),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = restricted,
|
||||
needApplyFCARestrictions = true,
|
||||
state = states.getValue(restricted),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN restrictions on WHEN resolveBadge for each FCA restricted id THEN FCAWarningList`() {
|
||||
// FCA badge must win regardless of rate — the restricted provider here is NOT the best rate.
|
||||
fcaRestrictedProviderIds.forEach { restrictedId ->
|
||||
val restricted = provider(id = restrictedId, type = ExchangeProviderType.CEX)
|
||||
val states = mapOf(
|
||||
restricted to quote(fromFiat = "100", toFiat = "110"),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "120"), // best, but not FCA restricted
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = restricted,
|
||||
needApplyFCARestrictions = true,
|
||||
state = states.getValue(restricted),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted provider but restrictions off WHEN resolveBadge THEN no FCA badge`() {
|
||||
val restricted = provider(id = "changelly", type = ExchangeProviderType.CEX)
|
||||
val states = mapOf(
|
||||
restricted to quote(fromFiat = "100", toFiat = "120"), // best rated
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = restricted,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(restricted),
|
||||
isSwapBestDexRateEnabled = false,
|
||||
)
|
||||
|
||||
// Restrictions are off → the restricted id is ignored and the normal best-rate badge wins.
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN restrictions on for a non-restricted best provider WHEN resolveBadge THEN best rate suppressed`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best & not restricted
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = true,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
// FCA restrictions globally on suppress the best-rate badge even for non-restricted providers.
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission required WHEN resolveBadge THEN PermissionRequired`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(
|
||||
fromFiat = "100",
|
||||
toFiat = "120",
|
||||
permission = PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
|
||||
),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN recommended provider WHEN resolveBadge THEN Recommended takes priority over best rate`() {
|
||||
val recommended = provider(id = "cex1", type = ExchangeProviderType.CEX, isRecommended = true)
|
||||
val states = mapOf(
|
||||
recommended to quote(fromFiat = "100", toFiat = "120"), // also the best rate
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = recommended,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(recommended),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN only one considered provider WHEN resolveBadge THEN Empty`() {
|
||||
val states = mapOf(cex1 to quote(fromFiat = "100", toFiat = "120"))
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider is not the best rated WHEN resolveBadge THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"), // best
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex2, // not the best
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex2),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rated provider but price impact warning WHEN resolveBadge THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120", priceImpactWarning = true), // best, but warning
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = states.getValue(cex1),
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no quote state (error row) WHEN resolveBadge THEN Empty`() {
|
||||
val states = mapOf(
|
||||
cex1 to quote(fromFiat = "100", toFiat = "120"),
|
||||
cex2 to quote(fromFiat = "100", toFiat = "110"),
|
||||
)
|
||||
|
||||
val badge = SwapProviderResolver.resolveBadge(
|
||||
states = states,
|
||||
provider = cex1,
|
||||
needApplyFCARestrictions = false,
|
||||
state = null,
|
||||
isSwapBestDexRateEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(badge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun provider(
|
||||
id: String,
|
||||
type: ExchangeProviderType,
|
||||
isRecommended: Boolean = false,
|
||||
): SwapProvider = mockk {
|
||||
every { providerId } returns id
|
||||
every { this@mockk.type } returns type
|
||||
every { this@mockk.isRecommended } returns isRecommended
|
||||
}
|
||||
|
||||
private fun quote(
|
||||
fromFiat: String,
|
||||
toFiat: String,
|
||||
priceImpactWarning: Boolean = false,
|
||||
permission: PermissionDataState = PermissionDataState.Empty,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val currency = mockk<CryptoCurrency.Coin> {
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val swapStatus = mockk<SwapCurrencyStatus> {
|
||||
every { this@mockk.currency } returns currency
|
||||
}
|
||||
val fromInfo = mockk<TokenSwapInfo> {
|
||||
every { amountFiat } returns BigDecimal(fromFiat)
|
||||
}
|
||||
val toInfo = mockk<TokenSwapInfo> {
|
||||
every { amountFiat } returns BigDecimal(toFiat)
|
||||
every { swapCurrencyStatus } returns swapStatus
|
||||
}
|
||||
return mockk {
|
||||
every { fromTokenInfo } returns fromInfo
|
||||
every { toTokenInfo } returns toInfo
|
||||
every { permissionState } returns permission
|
||||
every { priceImpact } returns mockk<PriceImpact> { every { shouldShowWarning() } returns priceImpactWarning }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
|
|||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.feature.swap.models.states.PercentDifference
|
||||
|
|
@ -20,6 +21,14 @@ import org.junit.jupiter.api.Test
|
|||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Tests for [SwapProviderStateBuilder].
|
||||
*
|
||||
* Badge selection lives in [SwapProviderResolver]; here the badge is supplied as [additionalBadge]
|
||||
* and the builder is expected to render it verbatim. These tests therefore focus on the builder's
|
||||
* own responsibilities — subtitle formatting, percent-delta mapping, provider identity, and passing
|
||||
* the badge through — not on badge-decision logic.
|
||||
*/
|
||||
internal class SwapProviderStateBuilderTest {
|
||||
|
||||
private var originalLocale: Locale = Locale.getDefault()
|
||||
|
|
@ -40,24 +49,20 @@ internal class SwapProviderStateBuilderTest {
|
|||
// region buildContentClickable
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentClickable THEN BestTrade badge`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false)
|
||||
fun `GIVEN a badge WHEN buildContentClickable THEN it is rendered with a rate subtitle`() {
|
||||
val provider = provider(id = "1inch")
|
||||
val from = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
val to = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("3000"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = from,
|
||||
toTokenInfo = to,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = to, fromTokenInfo = from),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.BestDexRate,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestDexRate)
|
||||
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
|
||||
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
|
||||
val subtitle = result.subtitle as TextReference.Str
|
||||
|
|
@ -65,106 +70,16 @@ internal class SwapProviderStateBuilderTest {
|
|||
assertThat(subtitle.value).contains("USDT")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN recommended provider WHEN buildContentClickable THEN Recommended badge`() {
|
||||
val provider = provider(id = "any", isRecommended = true)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission required WHEN buildContentClickable THEN PermissionRequired badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted provider WHEN buildContentClickable THEN FCAWarningList badge`() {
|
||||
val provider = provider(id = "changelly", isRecommended = true)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
needApplyFCARestrictions = true,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate badge disabled WHEN buildContentClickable THEN Empty badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider WHEN buildContentClickable THEN content carries provider identity`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false, name = "1inch", iconUrl = "https://x")
|
||||
val provider = provider(id = "1inch", name = "1inch", iconUrl = "https://x")
|
||||
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentClickable(
|
||||
provider = provider,
|
||||
fromTokenInfo = info,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = info),
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
isBestRate = false,
|
||||
isNeedBestRateBadge = false,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
|
|
@ -182,16 +97,15 @@ internal class SwapProviderStateBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `GIVEN provider in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is mapped`() {
|
||||
val provider = provider(id = "1inch", isRecommended = false)
|
||||
val provider = provider(id = "1inch")
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = info),
|
||||
pricesLowerBest = mapOf("1inch" to 0.5f),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
|
|
@ -203,16 +117,15 @@ internal class SwapProviderStateBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `GIVEN provider not in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is zero`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val provider = provider(id = "any")
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = info),
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
|
|
@ -220,84 +133,22 @@ internal class SwapProviderStateBuilderTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentSelectable THEN BestTrade badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
fun `GIVEN a badge WHEN buildContentSelectable THEN it is rendered`() {
|
||||
val provider = provider(id = "any")
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
state = quoteState(toTokenInfo = info),
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = true,
|
||||
additionalBadge = ProviderState.AdditionalBadge.BestTrade,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN isNeedBestRateBadge false WHEN buildContentSelectable THEN no BestTrade badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
isBestRate = true,
|
||||
isNeedBestRateBadge = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN isBestRate false AND badge enabled WHEN buildContentSelectable THEN no BestTrade badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.Empty,
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
isBestRate = false,
|
||||
isNeedBestRateBadge = true,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission required WHEN buildContentSelectable THEN PermissionRequired badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
|
||||
|
||||
val result = SwapProviderStateBuilder.buildContentSelectable(
|
||||
provider = provider,
|
||||
toTokenInfo = info,
|
||||
permissionState = PermissionDataState.PermissionRequired(
|
||||
isResetApproval = false,
|
||||
spenderAddress = "0xspender",
|
||||
),
|
||||
pricesLowerBest = emptyMap(),
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
|
||||
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
|
||||
assertThat((result.subtitle as TextReference.Str).value).contains("USDT")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -305,67 +156,23 @@ internal class SwapProviderStateBuilderTest {
|
|||
// region buildAvailableFrom
|
||||
|
||||
@Test
|
||||
fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text and badge is rendered`() {
|
||||
val provider = provider(id = "any")
|
||||
val alert: TextReference = stringReference("min amount 0.01 ETH")
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = alert,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
additionalBadge = ProviderState.AdditionalBadge.FCAWarningList,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.subtitle).isEqualTo(alert)
|
||||
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN FCA restricted WHEN buildAvailableFrom THEN FCAWarningList badge`() {
|
||||
val provider = provider(id = "okx-on-chain", isRecommended = true)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = true,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN recommended WHEN buildAvailableFrom THEN Recommended badge`() {
|
||||
val provider = provider(id = "any", isRecommended = true)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no flags WHEN buildAvailableFrom THEN Empty badge`() {
|
||||
val provider = provider(id = "any", isRecommended = false)
|
||||
|
||||
val result = SwapProviderStateBuilder.buildAvailableFrom(
|
||||
provider = provider,
|
||||
alertText = TextReference.EMPTY,
|
||||
selectionType = ProviderState.SelectionType.SELECT,
|
||||
needApplyFCARestrictions = false,
|
||||
onProviderClick = onProviderClick,
|
||||
)
|
||||
|
||||
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region buildSelectableSubtitle
|
||||
|
|
@ -386,7 +193,6 @@ internal class SwapProviderStateBuilderTest {
|
|||
|
||||
private fun provider(
|
||||
id: String,
|
||||
isRecommended: Boolean,
|
||||
name: String = "Provider",
|
||||
iconUrl: String = "https://icon",
|
||||
): SwapProvider = mockk {
|
||||
|
|
@ -394,7 +200,16 @@ internal class SwapProviderStateBuilderTest {
|
|||
every { this@mockk.name } returns name
|
||||
every { imageLarge } returns iconUrl
|
||||
every { type } returns ExchangeProviderType.DEX
|
||||
every { this@mockk.isRecommended } returns isRecommended
|
||||
}
|
||||
|
||||
private fun quoteState(
|
||||
toTokenInfo: TokenSwapInfo,
|
||||
fromTokenInfo: TokenSwapInfo = toTokenInfo,
|
||||
permissionState: PermissionDataState = PermissionDataState.Empty,
|
||||
): SwapState.QuotesLoadedState = mockk {
|
||||
every { this@mockk.fromTokenInfo } returns fromTokenInfo
|
||||
every { this@mockk.toTokenInfo } returns toTokenInfo
|
||||
every { this@mockk.permissionState } returns permissionState
|
||||
}
|
||||
|
||||
private fun tokenInfo(symbol: String, decimals: Int, amount: BigDecimal): TokenSwapInfo {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel.approvalFullCallback] (the full give-approval flow callback).
|
||||
*
|
||||
* Covers the synchronously-verifiable side effects. The quote-reload path
|
||||
* ([SwapModel] `startLoadingQuotesFromLastState`) short-circuits because `dataState.amount` is null
|
||||
* in these setups, keeping the callbacks side-effect-bounded.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class SwapModelApprovalFullCallbackTest : SwapModelTestBase() {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN token from-currency WHEN onApproveDone THEN adds contract address to in-progress`() = runTest {
|
||||
val model = createModel()
|
||||
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { contractAddress } returns "0xContract"
|
||||
}
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = swapCurrencyStatus(currency = token),
|
||||
)
|
||||
|
||||
model.approvalFullCallback.onApproveDone()
|
||||
|
||||
verify(exactly = 1) { allowPermissionsHandler.addAddressToInProgress("0xContract") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null from-currency WHEN onApproveDone THEN does not add address to in-progress`() = runTest {
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(fromSwapCurrencyStatus = null)
|
||||
|
||||
model.approvalFullCallback.onApproveDone()
|
||||
|
||||
verify(exactly = 0) { allowPermissionsHandler.addAddressToInProgress(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN failure WHEN onApproveFailed THEN sends alert message`() = runTest {
|
||||
val model = createModel()
|
||||
|
||||
model.approvalFullCallback.onApproveFailed()
|
||||
|
||||
verify(exactly = 1) { messageSender.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN onApproveClick THEN no interactions`() = runTest {
|
||||
val model = createModel()
|
||||
|
||||
// onApproveClick is intentionally a no-op; assert it does not crash.
|
||||
model.approvalFullCallback.onApproveClick()
|
||||
|
||||
verify(exactly = 0) { allowPermissionsHandler.addAddressToInProgress(any()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import io.mockk.coVerify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel.approvalSelectorCallback] (the [PermissionDataState.PermissionSettings]
|
||||
* approval-type selector).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class SwapModelApprovalSelectorCallbackTest : SwapModelTestBase() {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no current loaded state WHEN onApproveTypeSelected THEN no state change and no reload`() = runTest {
|
||||
val model = createModel()
|
||||
val before = model.dataState
|
||||
|
||||
model.approvalSelectorCallback.onApproveTypeSelected("0xSpender", ApproveType.UNLIMITED)
|
||||
|
||||
assertThat(model.dataState).isEqualTo(before)
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permission is not PermissionSettings WHEN onApproveTypeSelected THEN no reload`() = runTest {
|
||||
val provider = swapProvider()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(provider to quotesLoadedState(provider, PermissionDataState.Empty)),
|
||||
)
|
||||
|
||||
model.approvalSelectorCallback.onApproveTypeSelected("0xSpender", ApproveType.UNLIMITED)
|
||||
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same approve type WHEN onApproveTypeSelected THEN no state change and no reload`() = runTest {
|
||||
val provider = swapProvider()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(provider, permissionSettings(type = ApproveType.LIMITED)),
|
||||
),
|
||||
)
|
||||
val before = model.dataState
|
||||
|
||||
model.approvalSelectorCallback.onApproveTypeSelected("0xSpender", ApproveType.LIMITED)
|
||||
|
||||
assertThat(model.dataState).isEqualTo(before)
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN different approve type WHEN onApproveTypeSelected THEN updates state and triggers reload`() = runTest {
|
||||
val provider = swapProvider()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(provider, permissionSettings(type = ApproveType.LIMITED)),
|
||||
),
|
||||
)
|
||||
|
||||
model.approvalSelectorCallback.onApproveTypeSelected("0xSpender", ApproveType.UNLIMITED)
|
||||
|
||||
val updated = model.dataState.getCurrentLoadedSwapState()
|
||||
val settings = updated?.permissionState as? PermissionDataState.PermissionSettings
|
||||
assertThat(settings).isNotNull()
|
||||
assertThat(settings!!.type).isEqualTo(ApproveType.UNLIMITED)
|
||||
assertThat(model.feeSelectorRepository.state.value).isEqualTo(FeeSelectorUM.Loading)
|
||||
coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerLoadingState() }
|
||||
coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN any state WHEN onCancelClick THEN no state change and no reload`() = runTest {
|
||||
val provider = swapProvider()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(provider, permissionSettings()),
|
||||
),
|
||||
)
|
||||
val before = model.dataState
|
||||
|
||||
model.approvalSelectorCallback.onCancelClick()
|
||||
|
||||
assertThat(model.dataState).isEqualTo(before)
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
|
||||
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel]'s integrated approve+swap fee combination (private `combineTransactionFees`
|
||||
* / `sumEvmFees`). These are pure functions, so they are exercised via reflection (the public
|
||||
* fee-loading pipeline that calls them requires a large amount of async wiring; the plan permits
|
||||
* reflection for pure private functions where the public path is brittle).
|
||||
*
|
||||
* Verifies:
|
||||
* - Choosable + Choosable → per-bucket [TransactionFee.Choosable] with summed amount + gasLimit.
|
||||
* - Single involved (either side) → [TransactionFee.Single] summing the `normal` fees.
|
||||
* - Legacy EVM fees are summed too (amount + gasLimit).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class SwapModelCombineFeesTest : SwapModelTestBase() {
|
||||
|
||||
private lateinit var model: SwapModel
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
model = createModel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Choosable plus Choosable THEN per-bucket sum of amount and gasLimit`() {
|
||||
val approval = choosable(min = 1, normal = 2, priority = 3, gas = 21_000)
|
||||
val swap = choosable(min = 10, normal = 20, priority = 30, gas = 50_000)
|
||||
|
||||
val result = combineTransactionFees(approval, swap)
|
||||
|
||||
assertThat(result).isInstanceOf(TransactionFee.Choosable::class.java)
|
||||
val choosable = result as TransactionFee.Choosable
|
||||
assertEip1559(choosable.minimum, expectedValue = 11, expectedGas = 71_000)
|
||||
assertEip1559(choosable.normal, expectedValue = 22, expectedGas = 71_000)
|
||||
assertEip1559(choosable.priority, expectedValue = 33, expectedGas = 71_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Single approval and Choosable swap THEN result is Single summing normals`() {
|
||||
val approval = TransactionFee.Single(normal = eip1559(value = 2, gas = 21_000))
|
||||
val swap = choosable(min = 10, normal = 20, priority = 30, gas = 50_000)
|
||||
|
||||
val result = combineTransactionFees(approval, swap)
|
||||
|
||||
assertThat(result).isInstanceOf(TransactionFee.Single::class.java)
|
||||
assertEip1559((result as TransactionFee.Single).normal, expectedValue = 22, expectedGas = 71_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN both Single THEN result is Single summing normals`() {
|
||||
val approval = TransactionFee.Single(normal = eip1559(value = 5, gas = 21_000))
|
||||
val swap = TransactionFee.Single(normal = eip1559(value = 7, gas = 30_000))
|
||||
|
||||
val result = combineTransactionFees(approval, swap)
|
||||
|
||||
assertThat(result).isInstanceOf(TransactionFee.Single::class.java)
|
||||
assertEip1559((result as TransactionFee.Single).normal, expectedValue = 12, expectedGas = 51_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Legacy EVM fees THEN summed amount and gasLimit`() {
|
||||
val approval = TransactionFee.Single(normal = legacy(value = 2, gas = 21_000))
|
||||
val swap = TransactionFee.Single(normal = legacy(value = 20, gas = 50_000))
|
||||
|
||||
val result = combineTransactionFees(approval, swap)
|
||||
|
||||
val normal = (result as TransactionFee.Single).normal
|
||||
assertThat(normal).isInstanceOf(Fee.Ethereum.Legacy::class.java)
|
||||
val legacy = normal as Fee.Ethereum.Legacy
|
||||
assertThat(legacy.amount.value).isEqualTo(BigDecimal(22))
|
||||
assertThat(legacy.gasLimit).isEqualTo(BigInteger.valueOf(71_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadAndStoreIntegratedApproval stores IntegratedApprovalData on the quote state and returns combined fee`() =
|
||||
runTest {
|
||||
val provider = swapProvider()
|
||||
val quoteState = quotesLoadedState(
|
||||
provider = provider,
|
||||
permissionState = permissionSettings(type = ApproveType.UNLIMITED, spender = "0xSpender"),
|
||||
)
|
||||
model.dataState = model.dataState.copy(
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(provider to quoteState),
|
||||
)
|
||||
val approvalData = IntegratedApprovalData(
|
||||
approvalTransaction = mockk<TransactionData.Uncompiled>(relaxed = true),
|
||||
approvalFee = TransactionFee.Single(normal = eip1559(value = 2, gas = 21_000)),
|
||||
approveType = ApproveType.UNLIMITED,
|
||||
)
|
||||
coEvery {
|
||||
swapInteractor.loadIntegratedApprovalData(
|
||||
fromStatus = any(),
|
||||
spenderAddress = any(),
|
||||
approveType = any(),
|
||||
approvalAmount = any(),
|
||||
)
|
||||
} returns approvalData.right()
|
||||
|
||||
val combined = loadAndStoreIntegratedApproval(
|
||||
fromSwapCurrencyStatus = swapCurrencyStatus(),
|
||||
quoteState = quoteState,
|
||||
permissionSettings = permissionSettings(
|
||||
type = ApproveType.UNLIMITED,
|
||||
spender = "0xSpender",
|
||||
),
|
||||
approvalAmount = BigDecimal.ONE,
|
||||
swapTxFee = TransactionFee.Single(normal = eip1559(value = 20, gas = 50_000)),
|
||||
)
|
||||
|
||||
assertThat(combined.isRight()).isTrue()
|
||||
combined.onRight { fee ->
|
||||
assertEip1559((fee as TransactionFee.Single).normal, expectedValue = 22, expectedGas = 71_000)
|
||||
}
|
||||
// Stored on the current loaded state for later submission.
|
||||
val stored = (model.dataState.lastLoadedSwapStates[provider] as SwapState.QuotesLoadedState)
|
||||
.integratedApprovalData
|
||||
assertThat(stored).isEqualTo(approvalData)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private suspend fun loadAndStoreIntegratedApproval(
|
||||
fromSwapCurrencyStatus: com.tangem.domain.swap.models.SwapCurrencyStatus,
|
||||
quoteState: SwapState.QuotesLoadedState,
|
||||
permissionSettings: PermissionDataState.PermissionSettings,
|
||||
approvalAmount: BigDecimal,
|
||||
swapTxFee: TransactionFee,
|
||||
): arrow.core.Either<com.tangem.domain.transaction.error.GetFeeError, TransactionFee> {
|
||||
val method = SwapModel::class.java.declaredMethods.first { it.name == "loadAndStoreIntegratedApproval" }
|
||||
.apply { isAccessible = true }
|
||||
return invokeSuspend(method, fromSwapCurrencyStatus, quoteState, permissionSettings, approvalAmount, swapTxFee)
|
||||
as arrow.core.Either<com.tangem.domain.transaction.error.GetFeeError, TransactionFee>
|
||||
}
|
||||
|
||||
private suspend fun invokeSuspend(method: java.lang.reflect.Method, vararg args: Any?): Any? =
|
||||
kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn { cont ->
|
||||
method.invoke(model, *args, cont)
|
||||
}
|
||||
|
||||
private fun combineTransactionFees(approvalFee: TransactionFee, swapFee: TransactionFee): TransactionFee {
|
||||
val method = SwapModel::class.java.getDeclaredMethod(
|
||||
"combineTransactionFees",
|
||||
TransactionFee::class.java,
|
||||
TransactionFee::class.java,
|
||||
).apply { isAccessible = true }
|
||||
return method.invoke(model, approvalFee, swapFee) as TransactionFee
|
||||
}
|
||||
|
||||
private fun choosable(min: Int, normal: Int, priority: Int, gas: Long): TransactionFee.Choosable =
|
||||
TransactionFee.Choosable(
|
||||
minimum = eip1559(value = min, gas = gas),
|
||||
normal = eip1559(value = normal, gas = gas),
|
||||
priority = eip1559(value = priority, gas = gas),
|
||||
)
|
||||
|
||||
private fun eip1559(value: Int, gas: Long): Fee.Ethereum.EIP1559 = Fee.Ethereum.EIP1559(
|
||||
amount = ethAmount(value),
|
||||
gasLimit = BigInteger.valueOf(gas),
|
||||
maxFeePerGas = BigInteger.ONE,
|
||||
priorityFee = BigInteger.ONE,
|
||||
)
|
||||
|
||||
private fun legacy(value: Int, gas: Long): Fee.Ethereum.Legacy = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(value),
|
||||
gasLimit = BigInteger.valueOf(gas),
|
||||
gasPrice = BigInteger.ONE,
|
||||
)
|
||||
|
||||
private fun ethAmount(value: Int): Amount = Amount(
|
||||
currencySymbol = "ETH",
|
||||
value = BigDecimal(value),
|
||||
decimals = 18,
|
||||
)
|
||||
|
||||
private fun assertEip1559(fee: Fee, expectedValue: Int, expectedGas: Long) {
|
||||
assertThat(fee).isInstanceOf(Fee.Ethereum.EIP1559::class.java)
|
||||
val eip = fee as Fee.Ethereum.EIP1559
|
||||
assertThat(eip.amount.value).isEqualTo(BigDecimal(expectedValue))
|
||||
assertThat(eip.gasLimit).isEqualTo(BigInteger.valueOf(expectedGas))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.feature.swap.models.SwapPermissionUM
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel.getApprovalParams].
|
||||
*
|
||||
* Builds [com.tangem.features.approval.api.GiveApprovalComponent.Params] only when:
|
||||
* - `uiState.permissionUM` is [SwapPermissionUM.PermissionRequired]
|
||||
* - `fromSwapCurrencyStatus` is non-null
|
||||
* - `feePaidCryptoCurrency` is non-null
|
||||
*/
|
||||
internal class SwapModelGetApprovalParamsTest : SwapModelTestBase() {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN permissionUM is not PermissionRequired THEN returns null`() {
|
||||
val model = createModel()
|
||||
// default uiState has permissionUM == Empty
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = swapCurrencyStatus(),
|
||||
feePaidCryptoCurrency = mockk(relaxed = true),
|
||||
)
|
||||
|
||||
assertThat(model.getApprovalParams()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null fromSwapCurrencyStatus THEN returns null`() {
|
||||
val model = createModel()
|
||||
model.uiState = model.uiState.copy(
|
||||
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = false, spenderAddress = "0xSpender"),
|
||||
)
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = null,
|
||||
feePaidCryptoCurrency = mockk(relaxed = true),
|
||||
)
|
||||
|
||||
assertThat(model.getApprovalParams()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null feePaidCryptoCurrency THEN returns null`() {
|
||||
val model = createModel()
|
||||
model.uiState = model.uiState.copy(
|
||||
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = false, spenderAddress = "0xSpender"),
|
||||
)
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = swapCurrencyStatus(),
|
||||
feePaidCryptoCurrency = null,
|
||||
)
|
||||
|
||||
assertThat(model.getApprovalParams()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN give-approval prerequisites met THEN builds params with give footer`() {
|
||||
val model = createModel()
|
||||
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
|
||||
val currency = mockk<CryptoCurrency>(relaxed = true) { every { symbol } returns "DAI" }
|
||||
val status = mockk<CryptoCurrencyStatus>(relaxed = true)
|
||||
val from = swapCurrencyStatus(wallet = coldWallet, status = status, currency = currency)
|
||||
val feeStatus: CryptoCurrencyStatus = mockk(relaxed = true)
|
||||
val provider = swapProvider(name = "1inch")
|
||||
|
||||
model.uiState = model.uiState.copy(
|
||||
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = false, spenderAddress = "0xSpender"),
|
||||
)
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = from,
|
||||
feePaidCryptoCurrency = feeStatus,
|
||||
selectedProvider = provider,
|
||||
amount = "12.5",
|
||||
)
|
||||
|
||||
val params = model.getApprovalParams()
|
||||
|
||||
assertThat(params).isNotNull()
|
||||
assertThat(params!!.userWalletId).isEqualTo(userWalletId)
|
||||
assertThat(params.cryptoCurrencyStatus).isEqualTo(status)
|
||||
assertThat(params.feeCryptoCurrencyStatus).isEqualTo(feeStatus)
|
||||
assertThat(params.amount).isEqualTo("12.5")
|
||||
assertThat(params.spenderAddress).isEqualTo("0xSpender")
|
||||
assertThat(params.isResetApproval).isFalse()
|
||||
// Cold wallet -> not hold-to-confirm
|
||||
assertThat(params.isHoldToConfirm).isFalse()
|
||||
assertThat(params.callback).isSameInstanceAs(model.approvalFullCallback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN hot wallet THEN isHoldToConfirm is true`() {
|
||||
val model = createModel()
|
||||
val hotWallet = mockk<UserWallet.Hot>(relaxed = true)
|
||||
val from = swapCurrencyStatus(wallet = hotWallet)
|
||||
|
||||
model.uiState = model.uiState.copy(
|
||||
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = false, spenderAddress = "0xSpender"),
|
||||
)
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = from,
|
||||
feePaidCryptoCurrency = mockk(relaxed = true),
|
||||
amount = "1",
|
||||
)
|
||||
|
||||
val params = model.getApprovalParams()
|
||||
|
||||
assertThat(params).isNotNull()
|
||||
assertThat(params!!.isHoldToConfirm).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN reset approval THEN isResetApproval is true`() {
|
||||
val model = createModel()
|
||||
val from = swapCurrencyStatus()
|
||||
|
||||
model.uiState = model.uiState.copy(
|
||||
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = true, spenderAddress = "0xSpender"),
|
||||
)
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = from,
|
||||
feePaidCryptoCurrency = mockk(relaxed = true),
|
||||
amount = "1",
|
||||
)
|
||||
|
||||
val params = model.getApprovalParams()
|
||||
|
||||
assertThat(params).isNotNull()
|
||||
assertThat(params!!.isResetApproval).isTrue()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel.getSelectApprovalTypeParams].
|
||||
*
|
||||
* Builds [com.tangem.features.approval.api.SelectApprovalTypeComponent.Params] from the current
|
||||
* `fromSwapCurrencyStatus` and the provider's loaded swap state. Returns `null` when prerequisites
|
||||
* are missing. `initialApproveType` is taken from [PermissionDataState.PermissionSettings.type] or
|
||||
* falls back to [ApproveType.LIMITED].
|
||||
*/
|
||||
internal class SwapModelGetSelectApprovalTypeParamsTest : SwapModelTestBase() {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null fromSwapCurrencyStatus THEN returns null`() {
|
||||
val provider = swapProvider()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = null,
|
||||
lastLoadedSwapStates = mapOf(provider to quotesLoadedState(provider, permissionSettings())),
|
||||
)
|
||||
|
||||
assertThat(model.getSelectApprovalTypeParams(provider)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider state is not QuotesLoadedState THEN returns null`() {
|
||||
val provider = swapProvider()
|
||||
val notLoaded = SwapState.EmptyAmountState(zeroAmountEquivalent = TextReference.EMPTY)
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = swapCurrencyStatus(),
|
||||
lastLoadedSwapStates = mapOf(provider to notLoaded),
|
||||
)
|
||||
|
||||
assertThat(model.getSelectApprovalTypeParams(provider)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN PermissionSettings present THEN uses its type as initialApproveType`() {
|
||||
val provider = swapProvider(name = "ParaSwap")
|
||||
val currency = mockk<CryptoCurrency>(relaxed = true) { every { symbol } returns "USDT" }
|
||||
val status = mockk<CryptoCurrencyStatus>(relaxed = true)
|
||||
val from = swapCurrencyStatus(status = status, currency = currency)
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = from,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(provider, permissionSettings(type = ApproveType.UNLIMITED)),
|
||||
),
|
||||
)
|
||||
|
||||
val params = model.getSelectApprovalTypeParams(provider)
|
||||
|
||||
assertThat(params).isNotNull()
|
||||
assertThat(params!!.userWalletId).isEqualTo(userWalletId)
|
||||
assertThat(params.cryptoCurrencyStatus).isEqualTo(status)
|
||||
assertThat(params.initialApproveType).isEqualTo(ApproveType.UNLIMITED)
|
||||
assertThat(params.callback).isSameInstanceAs(model.approvalSelectorCallback)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.analytics.SwapEvents
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel]'s integrated-approval fallback trigger
|
||||
* (`SwapModel.FeeSelectorRepository.onResult` → private `handleFeeError`).
|
||||
*
|
||||
* Driven through the public `feeSelectorRepository.onResult(FeeSelectorUM.Error(...))` path:
|
||||
*
|
||||
* (a) `EstimateOverrideError` + `PermissionSettings` → `swapInteractor.integratedApprovalFallback`
|
||||
* is called once with the matching spender, and the loaded state is rewritten to
|
||||
* `PermissionRequired(isResetApproval = false)` with `integratedApprovalData == null`.
|
||||
* (b) `EstimateOverrideError` + non-`PermissionSettings` permission → no fallback call, state
|
||||
* left as-is (permission stays Empty).
|
||||
* (c) non-`EstimateOverrideError` (plain fee error) → no fallback call (plain fee-error path).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class SwapModelHandleFeeErrorTest : SwapModelTestBase() {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN EstimateOverrideError and PermissionSettings THEN fallback is triggered and state becomes PermissionRequired`() =
|
||||
runTest {
|
||||
val provider = swapProvider()
|
||||
val fromStatus = swapCurrencyStatus()
|
||||
val toStatus = swapCurrencyStatus()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(
|
||||
provider = provider,
|
||||
permissionState = permissionSettings(type = ApproveType.LIMITED, spender = SPENDER),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
model.feeSelectorRepository.onResult(
|
||||
FeeSelectorUM.Error(error = estimateOverrideError(), isHidden = false),
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
swapInteractor.integratedApprovalFallback(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
spenderAddress = SPENDER,
|
||||
)
|
||||
}
|
||||
// The gas-override analytics event must be reported once, carrying the error fields.
|
||||
verify(exactly = 1) {
|
||||
analyticsEventHandler.send(ofType(SwapEvents.ApproveGasOverrideError::class))
|
||||
}
|
||||
val sentEvents = mutableListOf<AnalyticsEvent>()
|
||||
verify { analyticsEventHandler.send(capture(sentEvents)) }
|
||||
val overrideEvent = sentEvents.filterIsInstance<SwapEvents.ApproveGasOverrideError>().single()
|
||||
assertThat(overrideEvent.params).isEqualTo(
|
||||
mapOf(
|
||||
"Token" to "USDT",
|
||||
"Blockchain" to "ethereum",
|
||||
"RPC Provider" to "infura",
|
||||
"Error Message" to "execution reverted",
|
||||
),
|
||||
)
|
||||
val updated = model.dataState.getCurrentLoadedSwapState()
|
||||
val permission = updated?.permissionState as? PermissionDataState.PermissionRequired
|
||||
assertThat(permission).isNotNull()
|
||||
assertThat(permission!!.isResetApproval).isFalse()
|
||||
assertThat(permission.spenderAddress).isEqualTo(SPENDER)
|
||||
assertThat(updated.integratedApprovalData).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN EstimateOverrideError and non-PermissionSettings THEN no fallback call`() = runTest {
|
||||
val provider = swapProvider()
|
||||
val fromStatus = swapCurrencyStatus()
|
||||
val toStatus = swapCurrencyStatus()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(provider = provider, permissionState = PermissionDataState.Empty),
|
||||
),
|
||||
)
|
||||
|
||||
model.feeSelectorRepository.onResult(
|
||||
FeeSelectorUM.Error(error = estimateOverrideError(), isHidden = false),
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
swapInteractor.integratedApprovalFallback(fromSwapCurrencyStatus = any(), spenderAddress = any())
|
||||
}
|
||||
// Permission untouched.
|
||||
assertThat(model.dataState.getCurrentLoadedSwapState()?.permissionState)
|
||||
.isEqualTo(PermissionDataState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-EstimateOverrideError THEN no fallback call (plain fee-error path)`() = runTest {
|
||||
val provider = swapProvider()
|
||||
val fromStatus = swapCurrencyStatus()
|
||||
val toStatus = swapCurrencyStatus()
|
||||
val model = createModel()
|
||||
// The plain path runs the model's StateBuilder/refresh; relaxed mocks cover it.
|
||||
// We assert only the absence of the fallback call.
|
||||
model.dataState = model.dataState.copy(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(
|
||||
provider to quotesLoadedState(
|
||||
provider = provider,
|
||||
permissionState = permissionSettings(type = ApproveType.LIMITED, spender = SPENDER),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
model.feeSelectorRepository.onResult(
|
||||
FeeSelectorUM.Error(error = GetFeeError.UnknownError, isHidden = false),
|
||||
)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
swapInteractor.integratedApprovalFallback(fromSwapCurrencyStatus = any(), spenderAddress = any())
|
||||
}
|
||||
// The gas-override analytics event belongs only to the EstimateOverrideError branch.
|
||||
verify(exactly = 0) {
|
||||
analyticsEventHandler.send(ofType(SwapEvents.ApproveGasOverrideError::class))
|
||||
}
|
||||
}
|
||||
|
||||
private fun estimateOverrideError() = GetFeeError.EstimateOverrideError(
|
||||
blockchain = "ethereum",
|
||||
tokenSymbol = "USDT",
|
||||
rpcProvider = "infura",
|
||||
error = "execution reverted",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val SPENDER = "0xSpender"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import io.mockk.every
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests for [SwapModel.isPermissionNotNeeded].
|
||||
*
|
||||
* The getter is `true` when the current loaded swap state needs no approval:
|
||||
* - always when [PermissionDataState.Empty]
|
||||
* - additionally for [PermissionDataState.PermissionSettings] when the integrated-approve toggle is ON
|
||||
*/
|
||||
internal class SwapModelIsPermissionNotNeededTest : SwapModelTestBase() {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
setUpBase()
|
||||
}
|
||||
|
||||
private fun modelWithPermissionState(
|
||||
permissionState: PermissionDataState,
|
||||
isIntegratedApproveEnabled: Boolean,
|
||||
): SwapModel {
|
||||
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns isIntegratedApproveEnabled
|
||||
val provider = swapProvider()
|
||||
val model = createModel()
|
||||
model.dataState = model.dataState.copy(
|
||||
selectedProvider = provider,
|
||||
lastLoadedSwapStates = mapOf(provider to quotesLoadedState(provider, permissionState)),
|
||||
)
|
||||
return model
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle OFF and Empty permission THEN permission is not needed`() {
|
||||
val model = modelWithPermissionState(PermissionDataState.Empty, isIntegratedApproveEnabled = false)
|
||||
|
||||
assertThat(model.isPermissionNotNeeded).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle OFF and PermissionSettings THEN permission is needed`() {
|
||||
val model = modelWithPermissionState(permissionSettings(), isIntegratedApproveEnabled = false)
|
||||
|
||||
assertThat(model.isPermissionNotNeeded).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle OFF and PermissionRequired THEN permission is needed`() {
|
||||
val model = modelWithPermissionState(
|
||||
PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
|
||||
isIntegratedApproveEnabled = false,
|
||||
)
|
||||
|
||||
assertThat(model.isPermissionNotNeeded).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle ON and Empty permission THEN permission is not needed`() {
|
||||
val model = modelWithPermissionState(PermissionDataState.Empty, isIntegratedApproveEnabled = true)
|
||||
|
||||
assertThat(model.isPermissionNotNeeded).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle ON and PermissionSettings THEN permission is not needed`() {
|
||||
val model = modelWithPermissionState(permissionSettings(), isIntegratedApproveEnabled = true)
|
||||
|
||||
assertThat(model.isPermissionNotNeeded).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toggle ON and PermissionRequired THEN permission is needed`() {
|
||||
val model = modelWithPermissionState(
|
||||
PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
|
||||
isIntegratedApproveEnabled = true,
|
||||
)
|
||||
|
||||
assertThat(model.isPermissionNotNeeded).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no current loaded state THEN permission is needed`() {
|
||||
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
|
||||
val model = createModel()
|
||||
|
||||
assertThat(model.isPermissionNotNeeded).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
package com.tangem.feature.swap.model
|
||||
|
||||
import arrow.core.right
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.analytics.api.AnalyticsErrorHandler
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.navigation.share.ShareManager
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
|
||||
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
|
||||
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
|
||||
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
|
||||
import com.tangem.domain.feedback.SendBackupProblemEmailUseCase
|
||||
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
|
||||
import com.tangem.domain.settings.usercountry.models.UserCountry
|
||||
import com.tangem.domain.stories.ShouldShowStoriesUseCase
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.swap.usecase.CalculateAmountUseCase
|
||||
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
|
||||
import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
|
||||
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
|
||||
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
|
||||
import com.tangem.feature.swap.domain.GetSwapUiModeUseCase
|
||||
import com.tangem.feature.swap.domain.SetSwapUiModeUseCase
|
||||
import com.tangem.feature.swap.domain.AllowPermissionsHandler
|
||||
import com.tangem.feature.swap.domain.SwapInteractor
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
|
||||
import com.tangem.feature.swap.ui.transfer.SwapTransferStateBuilder
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
|
||||
import com.tangem.features.swap.SwapComponent
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
/**
|
||||
* Shared test infrastructure for [SwapModel] unit tests.
|
||||
*
|
||||
* Wires every constructor dependency as a relaxed MockK and stubs the init-block calls so that
|
||||
* constructing the model has no observable side effects. See `swap-model-test-plan.md` for details.
|
||||
*/
|
||||
internal abstract class SwapModelTestBase {
|
||||
|
||||
protected val router: Router = mockk(relaxed = true)
|
||||
protected val appRouter: AppRouter = mockk(relaxed = true)
|
||||
protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
protected val analyticsErrorEventHandler: AnalyticsErrorHandler = mockk(relaxed = true)
|
||||
protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
|
||||
protected val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase = mockk(relaxed = true)
|
||||
protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase =
|
||||
mockk(relaxed = true)
|
||||
protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk(relaxed = true)
|
||||
protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk(relaxed = true)
|
||||
protected val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true)
|
||||
protected val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase =
|
||||
mockk(relaxed = true)
|
||||
protected val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase = mockk(relaxed = true)
|
||||
protected val shouldShowStoriesUseCase: ShouldShowStoriesUseCase = mockk(relaxed = true)
|
||||
protected val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk(relaxed = true)
|
||||
protected val swapInteractor: SwapInteractor = mockk(relaxed = true)
|
||||
protected val swapTransferInteractor: SwapTransferInteractor = mockk(relaxed = true)
|
||||
protected val swapTransferStateBuilder: SwapTransferStateBuilder = mockk(relaxed = true)
|
||||
protected val urlOpener: UrlOpener = mockk(relaxed = true)
|
||||
protected val shareManager: ShareManager = mockk(relaxed = true)
|
||||
protected val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk(relaxed = true)
|
||||
protected val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase =
|
||||
mockk(relaxed = true)
|
||||
protected val tangemPayWithdrawWithSwapUseCase: TangemPayWithdrawWithSwapUseCase = mockk(relaxed = true)
|
||||
protected val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk(relaxed = true)
|
||||
protected val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true)
|
||||
protected val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase = mockk(relaxed = true)
|
||||
protected val appsFlyerStore: AppsFlyerStore = mockk(relaxed = true)
|
||||
protected val messageSender: UiMessageSender = mockk(relaxed = true)
|
||||
protected val initialCurrenciesResolver: InitialCurrenciesResolver = mockk(relaxed = true)
|
||||
protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true)
|
||||
protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
|
||||
protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true)
|
||||
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
|
||||
protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true)
|
||||
protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true)
|
||||
protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true)
|
||||
|
||||
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
|
||||
private val getUserCountryUseCase: GetUserCountryUseCase = mockk(relaxed = true)
|
||||
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk(relaxed = true)
|
||||
|
||||
protected val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF")
|
||||
|
||||
/** Stubs init-block calls so [createModel] has no side effects. Call from `@BeforeEach`. */
|
||||
protected fun setUpBase() {
|
||||
val bridge = mockk<ChooseTokenBridge>(relaxed = true) {
|
||||
every { onCurrencyChosen } returns Channel()
|
||||
every { onClose } returns Channel()
|
||||
}
|
||||
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
|
||||
|
||||
every { getUserCountryUseCase.invokeSync() } returns UserCountry.Other("US").right()
|
||||
every { getBalanceHidingSettingsUseCase.invoke() } returns emptyFlow()
|
||||
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
||||
coEvery { shouldShowStoriesUseCase.invokeSync(any()) } returns false
|
||||
coEvery { initialCurrenciesResolver.invoke(any(), any(), any(), any()) } returns (null to null)
|
||||
every { getSelectedAppCurrencyUseCase.invoke() } returns emptyFlow()
|
||||
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
|
||||
}
|
||||
|
||||
protected fun createParams(): SwapComponent.Params = SwapComponent.Params(
|
||||
userWalletId = userWalletId,
|
||||
fromCryptoCurrency = null,
|
||||
screenSource = "Test",
|
||||
)
|
||||
|
||||
@Suppress("LongMethod")
|
||||
protected fun createModel(): SwapModel = SwapModel(
|
||||
paramsContainer = MutableParamsContainer(createParams()),
|
||||
getUserCountryUseCase = getUserCountryUseCase,
|
||||
getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase,
|
||||
chooseTokenBridgeFactory = chooseTokenBridgeFactory,
|
||||
router = router,
|
||||
appRouter = appRouter,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
analyticsErrorEventHandler = analyticsErrorEventHandler,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
updateDelayedCurrencyStatusUseCase = updateDelayedCurrencyStatusUseCase,
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
|
||||
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
|
||||
saveBlockchainErrorUseCase = saveBlockchainErrorUseCase,
|
||||
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
|
||||
getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase,
|
||||
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
|
||||
shouldShowStoriesUseCase = shouldShowStoriesUseCase,
|
||||
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
|
||||
swapInteractor = swapInteractor,
|
||||
swapTransferInteractor = swapTransferInteractor,
|
||||
swapTransferStateBuilder = swapTransferStateBuilder,
|
||||
urlOpener = urlOpener,
|
||||
shareManager = shareManager,
|
||||
getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase,
|
||||
getPaymentAccountCryptoCurrencyStatusUseCase = getPaymentAccountCryptoCurrencyStatusUseCase,
|
||||
tangemPayWithdrawWithSwapUseCase = tangemPayWithdrawWithSwapUseCase,
|
||||
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
|
||||
feeSelectorReloadTrigger = feeSelectorReloadTrigger,
|
||||
getTangemPayCustomerIdUseCase = getTangemPayCustomerIdUseCase,
|
||||
appsFlyerStore = appsFlyerStore,
|
||||
messageSender = messageSender,
|
||||
initialCurrenciesResolver = initialCurrenciesResolver,
|
||||
allowPermissionsHandler = allowPermissionsHandler,
|
||||
swapFeatureToggles = swapFeatureToggles,
|
||||
getSwapUiModeUseCase = getSwapUiModeUseCase,
|
||||
setSwapUiModeUseCase = setSwapUiModeUseCase,
|
||||
calculateAmountUseCase = calculateAmountUseCase,
|
||||
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
|
||||
sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase,
|
||||
)
|
||||
|
||||
// region builders
|
||||
|
||||
protected fun swapProvider(
|
||||
id: String = "provider-1",
|
||||
name: String = "1inch",
|
||||
type: ExchangeProviderType = ExchangeProviderType.DEX,
|
||||
): SwapProvider = SwapProvider(
|
||||
providerId = id,
|
||||
name = name,
|
||||
type = type,
|
||||
imageLarge = "",
|
||||
termsOfUse = null,
|
||||
privacyPolicy = null,
|
||||
slippage = null,
|
||||
)
|
||||
|
||||
protected fun permissionSettings(
|
||||
type: ApproveType = ApproveType.LIMITED,
|
||||
spender: String = "0xSpender",
|
||||
): PermissionDataState.PermissionSettings = PermissionDataState.PermissionSettings(
|
||||
type = type,
|
||||
spenderAddress = spender,
|
||||
)
|
||||
|
||||
protected fun quotesLoadedState(
|
||||
provider: SwapProvider,
|
||||
permissionState: PermissionDataState = PermissionDataState.Empty,
|
||||
integratedApprovalData: IntegratedApprovalData? = null,
|
||||
): SwapState.QuotesLoadedState = mockk(relaxed = true) {
|
||||
every { swapProvider } returns provider
|
||||
every { this@mockk.permissionState } returns permissionState
|
||||
every { this@mockk.integratedApprovalData } returns integratedApprovalData
|
||||
// Matcher for the copy(...) overload `handleFeeError` uses on the integrated-approval
|
||||
// fallback path: it copies `integratedApprovalData` (→ null) and `permissionState`
|
||||
// (→ PermissionRequired). Includes `integratedApprovalData` so MockK matches that call
|
||||
// and the rebuilt mock reflects the new permissionState / integratedApprovalData.
|
||||
every {
|
||||
copy(
|
||||
fromTokenInfo = any(),
|
||||
toTokenInfo = any(),
|
||||
priceImpact = any(),
|
||||
preparedSwapConfigState = any(),
|
||||
permissionState = any(),
|
||||
swapDataModel = any(),
|
||||
currencyCheck = any(),
|
||||
validationResult = any(),
|
||||
minAdaValue = any(),
|
||||
swapProvider = any(),
|
||||
integratedApprovalData = any(),
|
||||
)
|
||||
} answers {
|
||||
// `copy` arg indices follow the QuotesLoadedState primary-constructor order:
|
||||
// 0 fromTokenInfo, 1 toTokenInfo, 2 swapProvider, 3 priceImpact,
|
||||
// 4 preparedSwapConfigState, 5 permissionState, 6 swapDataModel,
|
||||
// 7 integratedApprovalData, 8 currencyCheck, 9 validationResult, 10 minAdaValue.
|
||||
quotesLoadedState(
|
||||
provider = provider,
|
||||
permissionState = arg(5),
|
||||
integratedApprovalData = arg(7),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun swapCurrencyStatus(
|
||||
wallet: UserWallet = mockk(relaxed = true),
|
||||
status: CryptoCurrencyStatus = mockk(relaxed = true),
|
||||
currency: CryptoCurrency = mockk(relaxed = true),
|
||||
): SwapCurrencyStatus = mockk(relaxed = true) {
|
||||
every { userWallet } returns wallet
|
||||
every { this@mockk.status } returns status
|
||||
every { this@mockk.currency } returns currency
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
// PER_METHOD (the JUnit5 default): a fresh instance per test, so the recorded-call fields never leak.
|
||||
internal class SwapAmountScreenClickIntentsTest {
|
||||
|
||||
private var changedValue: String? = null
|
||||
private var maxClicked = false
|
||||
private var currencyChangeIsFiat: Boolean? = null
|
||||
|
||||
// Real UiActions: the three wired callbacks record their invocation, the rest are no-ops.
|
||||
private val actions = UiActions(
|
||||
onAmountChanged = { changedValue = it },
|
||||
onCurrencyChange = { currencyChangeIsFiat = it },
|
||||
onAmountSelected = {},
|
||||
onSwapClick = {},
|
||||
onTransferClick = {},
|
||||
onChangeCardsClicked = {},
|
||||
onBackClicked = {},
|
||||
onMaxAmountSelected = { maxClicked = true },
|
||||
onPredefinedPercentSelected = {},
|
||||
onReduceToAmount = {},
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onApproveClick = {},
|
||||
onApproveTypeSelect = {},
|
||||
onRetryClick = {},
|
||||
onProviderClick = {},
|
||||
onProviderSelect = {},
|
||||
onProviderFilterSelect = {},
|
||||
openTokenDetailsScreen = {},
|
||||
onSelectTokenClick = {},
|
||||
onSuccess = {},
|
||||
onLinkClick = {},
|
||||
onReceiveCardWarningClick = {},
|
||||
onSwapUIModeChange = {},
|
||||
onSwapTypeMenuOpened = {},
|
||||
onTronBannerShown = {},
|
||||
)
|
||||
|
||||
private val sut = SwapAmountScreenClickIntents(actions)
|
||||
|
||||
@Test
|
||||
fun `GIVEN value WHEN onAmountValueChange THEN delegates to onAmountChanged`() {
|
||||
// Act
|
||||
sut.onAmountValueChange("12.34")
|
||||
|
||||
// Assert
|
||||
assertThat(changedValue).isEqualTo("12.34")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN max clicked WHEN onMaxValueClick THEN delegates to onMaxAmountSelected`() {
|
||||
// Act
|
||||
sut.onMaxValueClick()
|
||||
|
||||
// Assert
|
||||
assertThat(maxClicked).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fiat toggle WHEN onCurrencyChangeClick THEN delegates to onCurrencyChange`() {
|
||||
// Act
|
||||
sut.onCurrencyChangeClick(isFiat = true)
|
||||
|
||||
// Assert
|
||||
assertThat(currencyChangeIsFiat).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN paste dismiss and next WHEN invoked THEN they are no-ops and do not delegate`() {
|
||||
// Act
|
||||
sut.onAmountPasteTriggerDismiss()
|
||||
sut.onAmountNext()
|
||||
|
||||
// Assert — none of the wired callbacks fired
|
||||
assertThat(changedValue).isNull()
|
||||
assertThat(maxClicked).isFalse()
|
||||
assertThat(currencyChangeIsFiat).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.feature.swap.ui.transfer
|
|||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
|
|
@ -12,12 +13,18 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
|
||||
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.features.send.api.entity.CustomFeeFieldUM
|
||||
import com.tangem.features.send.api.entity.FeeItem
|
||||
import com.tangem.features.send.api.entity.FeeSelectorUM
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
|
@ -28,6 +35,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
private val sut = SwapTransferNotificationsFactory()
|
||||
|
||||
private val actions: UiActions = mockk(relaxed = true)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "deadbeef")
|
||||
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
|
|
@ -39,10 +48,9 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result).isEmpty()
|
||||
|
|
@ -61,10 +69,9 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Solana.RentInfo>()).hasSize(1)
|
||||
|
|
@ -81,16 +88,13 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
),
|
||||
currencyCheck = buildCurrencyCheck(existentialDeposit = BigDecimal("0.5")),
|
||||
)
|
||||
val fee: Fee = mockk(relaxed = true) {
|
||||
every { amount.value } returns BigDecimal("0.4")
|
||||
}
|
||||
val feeSelectorUM = contentWithFee(feeValue = BigDecimal("0.4"))
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = feeSelectorUM,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Error.ExistentialDeposit>()).hasSize(1)
|
||||
|
|
@ -109,10 +113,9 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Error.MinimumAmountError>()).hasSize(1)
|
||||
|
|
@ -127,10 +130,9 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Cardano.MinAdaValueCharged>()).hasSize(1)
|
||||
|
|
@ -150,10 +152,9 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeCoverageNotification>()).hasSize(1)
|
||||
|
|
@ -172,10 +173,9 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
val reserve = result.filterIsInstance<SwapNotificationUM.Warning.NeedReserveToCreateAccount>()
|
||||
|
|
@ -195,34 +195,265 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<SwapNotificationUM.Warning.ReduceAmount>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN BalanceNotEnoughForFee warning WHEN getNotifications THEN TokenExceedsBalance is added`() = runTest {
|
||||
val warning = CryptoCurrencyWarning.BalanceNotEnoughForFee(
|
||||
tokenCurrency = buildCoin(),
|
||||
coinCurrency = buildCoin(),
|
||||
)
|
||||
val transferState = buildTransferState(
|
||||
cryptoCurrencyWarning = warning,
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Error.TokenExceedsBalance>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron token and show count within limit WHEN getNotifications THEN Tron network fees Info is added`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState(
|
||||
fromTokenInfo = buildTokenInfo(
|
||||
swapCurrencyStatus = buildTronTokenStatus(),
|
||||
amount = BigDecimal("10"),
|
||||
),
|
||||
tronFeeNotificationShowCount = TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT,
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron token but show count exceeds limit WHEN getNotifications THEN no Tron network fees Info`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState(
|
||||
fromTokenInfo = buildTokenInfo(
|
||||
swapCurrencyStatus = buildTronTokenStatus(),
|
||||
amount = BigDecimal("10"),
|
||||
),
|
||||
tronFeeNotificationShowCount = TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT + 1,
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron coin (not token) WHEN getNotifications THEN no Tron network fees Info`() = runTest {
|
||||
val transferState = buildTransferState(
|
||||
fromTokenInfo = buildTokenInfo(
|
||||
swapCurrencyStatus = buildCoinStatus(rawNetworkId = "tron"),
|
||||
amount = BigDecimal("10"),
|
||||
),
|
||||
tronFeeNotificationShowCount = 0,
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN UnknownError fee error and fee currency status WHEN getNotifications THEN NetworkFeeUnreachable is added`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState()
|
||||
val feeCryptoCurrencyStatus = buildCoinStatus().status
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = errorSelector(GetFeeError.UnknownError),
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN TronActivationError fee error and fee currency status WHEN getNotifications THEN TronAccountNotActivated is added`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState()
|
||||
val feeCryptoCurrencyStatus = buildCoinStatus().status
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = errorSelector(GetFeeError.BlockchainErrors.TronActivationError),
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
val notifications = result.filterIsInstance<NotificationUM.Warning.TronAccountNotActivated>()
|
||||
assertThat(notifications).hasSize(1)
|
||||
assertThat(notifications.first().tokenName).isEqualTo(feeCryptoCurrencyStatus.currency.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee error but null fee currency status WHEN getNotifications THEN no fee unreachable notification`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState()
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = errorSelector(GetFeeError.UnknownError),
|
||||
feeCryptoCurrencyStatus = null,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null fee error and fee currency status WHEN getNotifications THEN no fee unreachable notification`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState()
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = null,
|
||||
feeCryptoCurrencyStatus = buildCoinStatus().status,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN custom fee below network minimum WHEN getNotifications THEN FeeTooLow is added`() = runTest {
|
||||
val transferState = buildTransferState()
|
||||
val feeSelectorUM = contentWithCustomFeeBelowMinimum(
|
||||
customFeeValue = "0.0001",
|
||||
minimumFeeValue = BigDecimal("0.001"),
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = feeSelectorUM,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeTooLow>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN custom fee at network minimum WHEN getNotifications THEN no FeeTooLow`() = runTest {
|
||||
val transferState = buildTransferState()
|
||||
val feeSelectorUM = contentWithCustomFeeBelowMinimum(
|
||||
customFeeValue = "0.001",
|
||||
minimumFeeValue = BigDecimal("0.001"),
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = feeSelectorUM,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
actions = actions,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeTooLow>()).isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [FeeSelectorUM.Content] with a Custom fee whose [customFeeValue] is below the choosable
|
||||
* [minimumFeeValue], so
|
||||
* [com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkIfCustomFeeTooLow]
|
||||
* reports the fee as too low. The choosable `priority` is left unstubbed (null) so the sibling
|
||||
* `checkIfCustomFeeTooHigh` short-circuits and does not add a spurious TooHigh notification.
|
||||
*/
|
||||
private fun contentWithCustomFeeBelowMinimum(
|
||||
customFeeValue: String,
|
||||
minimumFeeValue: BigDecimal,
|
||||
decimals: Int = 8,
|
||||
): FeeSelectorUM.Content {
|
||||
val customField: CustomFeeFieldUM = mockk(relaxed = true) {
|
||||
every { value } returns customFeeValue
|
||||
every { this@mockk.decimals } returns decimals
|
||||
}
|
||||
val customFeeItem: FeeItem.Custom = mockk(relaxed = true) {
|
||||
every { customValues } returns persistentListOf(customField)
|
||||
}
|
||||
val choosableFees: TransactionFee.Choosable = mockk(relaxed = true) {
|
||||
every { minimum.amount.value } returns minimumFeeValue
|
||||
}
|
||||
return mockk(relaxed = true) {
|
||||
every { selectedFeeItem } returns customFeeItem
|
||||
every { fees } returns choosableFees
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [FeeSelectorUM.Content] whose selected fee carries [feeValue]. A non-Custom fee item is used so
|
||||
* [com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkIfCustomFeeTooHigh]
|
||||
* short-circuits and does not add a spurious TooHigh notification.
|
||||
*/
|
||||
private fun contentWithFee(feeValue: BigDecimal): FeeSelectorUM.Content {
|
||||
val fee: Fee = mockk(relaxed = true) {
|
||||
every { amount.value } returns feeValue
|
||||
}
|
||||
return mockk(relaxed = true) {
|
||||
every { selectedFeeItem } returns FeeItem.Market(fee = fee)
|
||||
}
|
||||
}
|
||||
|
||||
private fun errorSelector(error: GetFeeError): FeeSelectorUM.Error = FeeSelectorUM.Error(error = error)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun buildTransferState(
|
||||
fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
|
||||
toTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
|
||||
cryptoCurrencyWarning: CryptoCurrencyWarning? = null,
|
||||
currencyCheck: CryptoCurrencyCheck? = null,
|
||||
validationResult: Throwable? = null,
|
||||
minAdaValue: BigDecimal? = null,
|
||||
isFeeCoverage: Boolean = false,
|
||||
sendingAmount: BigDecimal = fromTokenInfo.tokenAmount.value,
|
||||
tronFeeNotificationShowCount: Int = 0,
|
||||
isAmountSubtractAvailable: Boolean = false,
|
||||
): SwapState.Transfer = SwapState.Transfer(
|
||||
userWallet = coldWallet,
|
||||
fromTokenInfo = fromTokenInfo,
|
||||
toTokenInfo = toTokenInfo,
|
||||
cryptoCurrencyWarning = cryptoCurrencyWarning,
|
||||
isInsufficientBalance = false,
|
||||
appCurrency = AppCurrency.Default,
|
||||
isBalanceHidden = false,
|
||||
isAccountsMode = false,
|
||||
isFeeCoverage = isFeeCoverage,
|
||||
sendingAmount = sendingAmount,
|
||||
tronFeeNotificationShowCount = tronFeeNotificationShowCount,
|
||||
isAmountSubtractAvailable = isAmountSubtractAvailable,
|
||||
currencyCheck = currencyCheck,
|
||||
validationResult = validationResult,
|
||||
minAdaValue = minAdaValue,
|
||||
|
|
@ -294,4 +525,31 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
every { decimals } returns 18
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTronTokenStatus(): SwapCurrencyStatus {
|
||||
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { id } returns mockk(relaxed = true)
|
||||
every { network } returns mockk(relaxed = true) {
|
||||
every { rawId } returns "tron"
|
||||
every { name } returns "Tron"
|
||||
}
|
||||
every { name } returns "Tether"
|
||||
every { symbol } returns "USDT"
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val statusValue: CryptoCurrencyStatus.Loaded = mockk(relaxed = true) {
|
||||
every { amount } returns BigDecimal("100")
|
||||
every { fiatRate } returns BigDecimal.ONE
|
||||
every { fiatAmount } returns BigDecimal("100")
|
||||
}
|
||||
return SwapCurrencyStatus(
|
||||
userWallet = coldWallet,
|
||||
status = CryptoCurrencyStatus(currency = token, value = statusValue),
|
||||
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,18 @@
|
|||
package com.tangem.feature.swap.ui.transfer
|
||||
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.common.ui.account.AccountTitleUM
|
||||
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.common.ui.navigationButtons.NavigationUM
|
||||
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
|
|
@ -18,6 +22,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto
|
|||
import com.tangem.core.ui.format.bigdecimal.fee
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -34,12 +39,14 @@ import com.tangem.feature.swap.model.SwapProcessDataState
|
|||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
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
|
||||
|
|
@ -52,10 +59,9 @@ internal class SwapTransferStateBuilderTest {
|
|||
coEvery {
|
||||
getNotifications(
|
||||
transferState = any(),
|
||||
feeSelectorUM = any(),
|
||||
feeCryptoCurrencyStatus = any(),
|
||||
fee = any(),
|
||||
onReduceByAmount = any(),
|
||||
onReduceToAmount = any(),
|
||||
actions = any(),
|
||||
)
|
||||
} returns persistentListOf()
|
||||
}
|
||||
|
|
@ -67,6 +73,21 @@ internal class SwapTransferStateBuilderTest {
|
|||
isFeeApproximateUseCase = isFeeApproximateUseCase,
|
||||
)
|
||||
|
||||
// PER_CLASS reuses the notificationsFactory mock across tests, so clear its recorded calls (and re-stub)
|
||||
// before each test to keep coVerify(exactly = 1) scoped to the current test.
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(notificationsFactory)
|
||||
coEvery {
|
||||
notificationsFactory.getNotifications(
|
||||
transferState = any(),
|
||||
feeSelectorUM = any(),
|
||||
feeCryptoCurrencyStatus = any(),
|
||||
actions = any(),
|
||||
)
|
||||
} returns persistentListOf()
|
||||
}
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "deadbeef")
|
||||
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
|
|
@ -76,9 +97,31 @@ internal class SwapTransferStateBuilderTest {
|
|||
private val iconConverter = CryptoCurrencyToIconStateConverter()
|
||||
private val fromIcon = iconConverter.convert(fromCurrencyStatus.status)
|
||||
private val toIcon = iconConverter.convert(toCurrencyStatus.status)
|
||||
private val initialAmountTextFieldValue = TextFieldValue(
|
||||
text = "0.5",
|
||||
selection = TextRange(index = 3),
|
||||
private val initialAmountValue = "0.5"
|
||||
private val initialAmountField = AmountFieldModel(
|
||||
value = initialAmountValue,
|
||||
onValueChange = {},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Number),
|
||||
keyboardActions = KeyboardActions(),
|
||||
cryptoAmount = com.tangem.domain.tokens.model.Amount(
|
||||
currencySymbol = "",
|
||||
value = BigDecimal("0.5"),
|
||||
decimals = 18,
|
||||
),
|
||||
fiatAmount = com.tangem.domain.tokens.model.Amount(
|
||||
currencySymbol = "$",
|
||||
value = BigDecimal("0.5"),
|
||||
decimals = 2,
|
||||
type = com.tangem.domain.tokens.model.AmountType.FiatType("USD"),
|
||||
),
|
||||
isFiatValue = false,
|
||||
fiatValue = "",
|
||||
isFiatUnavailable = false,
|
||||
isValuePasted = false,
|
||||
onValuePastedTriggerDismiss = {},
|
||||
isError = false,
|
||||
isWarning = false,
|
||||
error = TextReference.EMPTY,
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -96,14 +139,15 @@ internal class SwapTransferStateBuilderTest {
|
|||
transferState = transferState,
|
||||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
|
||||
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
|
||||
val expectedAccountName = portfolioAccount.accountName.toUM().value
|
||||
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
|
||||
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
|
||||
val receiveType =
|
||||
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
|
||||
assertThat(sendType.accountTitleUM).isEqualTo(
|
||||
AccountTitleUM.Account(
|
||||
prefixText = resourceReference(R.string.swapping_from_account_title),
|
||||
|
|
@ -125,10 +169,9 @@ internal class SwapTransferStateBuilderTest {
|
|||
coVerify(exactly = 1) {
|
||||
notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = any(),
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = any(),
|
||||
onReduceToAmount = any(),
|
||||
actions = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -148,11 +191,12 @@ internal class SwapTransferStateBuilderTest {
|
|||
transferState = transferState,
|
||||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
|
||||
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
|
||||
val receiveType =
|
||||
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
|
||||
assertThat(sendType.accountTitleUM).isEqualTo(
|
||||
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
|
||||
)
|
||||
|
|
@ -166,10 +210,9 @@ internal class SwapTransferStateBuilderTest {
|
|||
coVerify(exactly = 1) {
|
||||
notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = any(),
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = any(),
|
||||
onReduceToAmount = any(),
|
||||
actions = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -190,11 +233,12 @@ internal class SwapTransferStateBuilderTest {
|
|||
transferState = transferState,
|
||||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
|
||||
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
|
||||
val receiveType =
|
||||
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
|
||||
assertThat(sendType.accountTitleUM).isEqualTo(
|
||||
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
|
||||
)
|
||||
|
|
@ -208,10 +252,9 @@ internal class SwapTransferStateBuilderTest {
|
|||
coVerify(exactly = 1) {
|
||||
notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = any(),
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = any(),
|
||||
onReduceToAmount = any(),
|
||||
actions = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -232,14 +275,15 @@ internal class SwapTransferStateBuilderTest {
|
|||
transferState = transferState,
|
||||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio
|
||||
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
|
||||
val expectedAccountName = portfolioAccount.accountName.toUM().value
|
||||
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
|
||||
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
|
||||
val receiveType =
|
||||
(result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
|
||||
assertThat(sendType.accountTitleUM).isEqualTo(
|
||||
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
|
||||
)
|
||||
|
|
@ -256,10 +300,9 @@ internal class SwapTransferStateBuilderTest {
|
|||
coVerify(exactly = 1) {
|
||||
notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = any(),
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = any(),
|
||||
onReduceToAmount = any(),
|
||||
actions = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -303,10 +346,9 @@ internal class SwapTransferStateBuilderTest {
|
|||
coEvery {
|
||||
notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = any(),
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
onReduceByAmount = any(),
|
||||
onReduceToAmount = any(),
|
||||
actions = any(),
|
||||
)
|
||||
} returns persistentListOf()
|
||||
|
||||
|
|
@ -317,6 +359,8 @@ internal class SwapTransferStateBuilderTest {
|
|||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
isTangemPayWithdrawal = false,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isTrue()
|
||||
|
|
@ -325,14 +369,131 @@ internal class SwapTransferStateBuilderTest {
|
|||
coVerify(exactly = 1) {
|
||||
notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeSelectorUM = any(),
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
onReduceByAmount = any(),
|
||||
onReduceToAmount = any(),
|
||||
actions = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee coverage reduces amount WHEN createTransferState THEN receive card shows reduced sendingAmount`() =
|
||||
runTest {
|
||||
// entered 1.5, but fee coverage reduces the received (sending) amount to 1.3
|
||||
val sendingAmount = BigDecimal("1.3")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = BigDecimal("1.5"),
|
||||
toAmount = sendingAmount,
|
||||
isAccountsMode = false,
|
||||
isFeeCoverage = true,
|
||||
)
|
||||
|
||||
val result = sut.createTransferState(
|
||||
actions = actions,
|
||||
transferState = transferState,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
val sendCard = result.sendCardData as SwapCardState.SwapCardData
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
val expectedFiat = stringReference(
|
||||
toCurrencyStatus.status.value.fiatRate!!.multiply(sendingAmount).format {
|
||||
fiat(fiatCurrencyCode = AppCurrency.Default.code, fiatCurrencySymbol = AppCurrency.Default.symbol)
|
||||
},
|
||||
)
|
||||
// the "from" card keeps the user's typed value, the receive card shows the reduced amount + its fiat
|
||||
assertThat(sendCard.amountField?.value).isEqualTo(initialAmountValue)
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(
|
||||
sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
|
||||
)
|
||||
assertThat(receiveCard.amountEquivalent).isEqualTo(expectedFiat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN sendingAmount loading WHEN createTransferState THEN receive card amount and fiat shimmer`() =
|
||||
runTest {
|
||||
// fee not loaded yet → reduced amount unknown → receive card shimmers (null amount + null fiat)
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = BigDecimal("1.5"),
|
||||
toAmount = BigDecimal("1.5"),
|
||||
isAccountsMode = false,
|
||||
isSendingAmountLoading = true,
|
||||
)
|
||||
|
||||
val result = sut.createTransferState(
|
||||
actions = actions,
|
||||
transferState = transferState,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
assertThat(receiveCard.amountField).isNull()
|
||||
assertThat(receiveCard.amountEquivalent).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN sendingAmount loading but insufficient balance WHEN createTransferState THEN receive card shows amount not shimmer`() =
|
||||
runTest {
|
||||
// Insufficient balance → fee coverage can't apply, so there is no reduction to wait for.
|
||||
// The receive card must show the amount instead of shimmering.
|
||||
val amount = BigDecimal("99")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = amount,
|
||||
toAmount = amount,
|
||||
isAccountsMode = false,
|
||||
isInsufficientBalance = true,
|
||||
isSendingAmountLoading = true,
|
||||
)
|
||||
|
||||
val result = sut.createTransferState(
|
||||
actions = actions,
|
||||
transferState = transferState,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(
|
||||
amount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
|
||||
)
|
||||
assertThat(receiveCard.amountEquivalent).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN reduced sendingAmount WHEN updateTransferButtonEnableState THEN receive card is rebuilt to reduced amount`() =
|
||||
runTest {
|
||||
// After the fee resolves, sendingAmount is reduced; the refresh path must rebuild the receive card
|
||||
// so it no longer shows the stale full amount.
|
||||
val sendingAmount = BigDecimal("1.3")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = BigDecimal("1.5"),
|
||||
toAmount = sendingAmount,
|
||||
isAccountsMode = false,
|
||||
isFeeCoverage = true,
|
||||
)
|
||||
|
||||
val result = sut.updateTransferButtonEnableState(
|
||||
dataState = SwapProcessDataState(),
|
||||
transferState = transferState,
|
||||
actions = actions,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = mockk(relaxed = true),
|
||||
isTangemPayWithdrawal = false,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(
|
||||
sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron fee WHEN updateTransferButtonEnableState THEN transferFooter uses Tron token fee sending text`() =
|
||||
runTest {
|
||||
|
|
@ -358,6 +519,8 @@ internal class SwapTransferStateBuilderTest {
|
|||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
isTangemPayWithdrawal = false,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
assertThat(result.transferFooter).isInstanceOf(TextReference.Combined::class.java)
|
||||
|
|
@ -365,10 +528,10 @@ internal class SwapTransferStateBuilderTest {
|
|||
assertThat(refs).hasSize(3)
|
||||
assertThat(refs[0]).isInstanceOf(TextReference.Res::class.java)
|
||||
assertThat((refs[0] as TextReference.Res).id)
|
||||
.isEqualTo(com.tangem.features.send.v2.api.R.string.send_summary_transaction_description_prefix)
|
||||
.isEqualTo(com.tangem.features.send.api.R.string.send_summary_transaction_description_prefix)
|
||||
assertThat(refs[2]).isInstanceOf(TextReference.Res::class.java)
|
||||
assertThat((refs[2] as TextReference.Res).id)
|
||||
.isEqualTo(com.tangem.features.send.v2.api.R.string.send_summary_transaction_description_suffix_fee_covered)
|
||||
.isEqualTo(com.tangem.features.send.api.R.string.send_summary_transaction_description_suffix_fee_covered)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -381,20 +544,27 @@ internal class SwapTransferStateBuilderTest {
|
|||
isAccountsMode = false,
|
||||
)
|
||||
val statusWithNetwork = buildStatusWithNetwork(hasFiatFeeRate = true)
|
||||
val dataState = SwapProcessDataState(fromSwapCurrencyStatus = statusWithNetwork)
|
||||
// The fee's fiat value is derived from the fee-paid currency's fiat rate, not the raw crypto fee.
|
||||
val feePaidStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val feePaidRate = feePaidStatus.status.value.fiatRate!!
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = statusWithNetwork,
|
||||
feePaidCryptoCurrency = feePaidStatus.status,
|
||||
)
|
||||
val feeValue = BigDecimal("0.001")
|
||||
val fee = Fee.Common(
|
||||
amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18),
|
||||
)
|
||||
val uiState = baseStateHolder()
|
||||
val appCurrency = transferState.appCurrency
|
||||
val expectedFiatSending = (fromAmount * QUOTE).plus(feeValue).format {
|
||||
val fiatFeeValue = feePaidRate.multiply(feeValue)
|
||||
val expectedFiatSending = (fromAmount * QUOTE).plus(fiatFeeValue).format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
)
|
||||
}
|
||||
val expectedFiatFee = feeValue.format {
|
||||
val expectedFiatFee = fiatFeeValue.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
|
|
@ -408,11 +578,111 @@ internal class SwapTransferStateBuilderTest {
|
|||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
isTangemPayWithdrawal = false,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
assertThat(result.transferFooter).isEqualTo(
|
||||
resourceReference(
|
||||
id = com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description,
|
||||
id = com.tangem.features.send.impl.R.string.send_summary_transaction_description,
|
||||
formatArgs = wrappedList(expectedFiatSending, expectedFiatFee),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee subtracted from amount WHEN updateTransferButtonEnableState THEN footer sending excludes the fee`() =
|
||||
runTest {
|
||||
// Arrange: subtraction available + amount + fee exceeds balance (1.0), but fee (0.5) <= balance.
|
||||
// The entered amount is the gross that already includes the fee, so the footer must not add it again.
|
||||
val fromAmount = BigDecimal("1.0")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = fromAmount,
|
||||
toAmount = fromAmount,
|
||||
isAccountsMode = false,
|
||||
isAmountSubtractAvailable = true,
|
||||
)
|
||||
val feePaidStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val feePaidRate = feePaidStatus.status.value.fiatRate!!
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = buildStatusWithNetwork(hasFiatFeeRate = true),
|
||||
feePaidCryptoCurrency = feePaidStatus.status,
|
||||
)
|
||||
val feeValue = BigDecimal("0.5")
|
||||
val fee = Fee.Common(amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18))
|
||||
val appCurrency = transferState.appCurrency
|
||||
// Sending is the entered amount only — the fee is NOT added on top.
|
||||
val expectedFiatSending = (fromAmount * QUOTE).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
val expectedFiatFee = feePaidRate.multiply(feeValue).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
|
||||
// Act
|
||||
val result = sut.updateTransferButtonEnableState(
|
||||
dataState = dataState,
|
||||
transferState = transferState,
|
||||
actions = actions,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
isTangemPayWithdrawal = false,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.transferFooter).isEqualTo(
|
||||
resourceReference(
|
||||
id = com.tangem.features.send.impl.R.string.send_summary_transaction_description,
|
||||
formatArgs = wrappedList(expectedFiatSending, expectedFiatFee),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee exceeds balance WHEN updateTransferButtonEnableState THEN footer sending is zero`() =
|
||||
runTest {
|
||||
// Arrange: subtraction available + fee (2.0) exceeds balance (1.0) → nothing can be sent.
|
||||
val fromAmount = BigDecimal("1.0")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = fromAmount,
|
||||
toAmount = fromAmount,
|
||||
isAccountsMode = false,
|
||||
isAmountSubtractAvailable = true,
|
||||
)
|
||||
val feePaidStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val feePaidRate = feePaidStatus.status.value.fiatRate!!
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = buildStatusWithNetwork(hasFiatFeeRate = true),
|
||||
feePaidCryptoCurrency = feePaidStatus.status,
|
||||
)
|
||||
val feeValue = BigDecimal("2.0")
|
||||
val fee = Fee.Common(amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18))
|
||||
val appCurrency = transferState.appCurrency
|
||||
val expectedFiatSending = BigDecimal.ZERO.format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
val expectedFiatFee = feePaidRate.multiply(feeValue).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
|
||||
// Act
|
||||
val result = sut.updateTransferButtonEnableState(
|
||||
dataState = dataState,
|
||||
transferState = transferState,
|
||||
actions = actions,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
isTangemPayWithdrawal = false,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.transferFooter).isEqualTo(
|
||||
resourceReference(
|
||||
id = com.tangem.features.send.impl.R.string.send_summary_transaction_description,
|
||||
formatArgs = wrappedList(expectedFiatSending, expectedFiatFee),
|
||||
),
|
||||
)
|
||||
|
|
@ -452,11 +722,13 @@ internal class SwapTransferStateBuilderTest {
|
|||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
isTangemPayWithdrawal = false,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
assertThat(result.transferFooter).isEqualTo(
|
||||
resourceReference(
|
||||
id = com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description_no_fiat_fee,
|
||||
id = com.tangem.features.send.impl.R.string.send_summary_transaction_description_no_fiat_fee,
|
||||
formatArgs = wrappedList(expectedFiatSending, expectedFiatFee),
|
||||
),
|
||||
)
|
||||
|
|
@ -464,26 +736,44 @@ internal class SwapTransferStateBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() {
|
||||
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
|
||||
val amount = BigDecimal("1.5")
|
||||
// from amount comes from dataState.amount, to amount from transferState.sendingAmount — keep them distinct
|
||||
val fromAmount = BigDecimal("2.0")
|
||||
val toAmount = BigDecimal("1.5")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = fromAmount,
|
||||
toAmount = toAmount,
|
||||
isAccountsMode = true,
|
||||
)
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = fromCurrencyStatus,
|
||||
toSwapCurrencyStatus = toCurrencyStatus,
|
||||
amount = amount.toPlainString(),
|
||||
amount = fromAmount.toPlainString(),
|
||||
currentTransferState = transferState,
|
||||
)
|
||||
val feeValue = BigDecimal("0.001")
|
||||
val fee = Fee.Common(
|
||||
amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18),
|
||||
)
|
||||
val appCurrency = transferState.appCurrency
|
||||
val expectedFee = stringReference(
|
||||
"${feeValue.format { crypto(symbol = "ETH", decimals = 18) }} " +
|
||||
"(${
|
||||
fromCurrencyStatus.status.value.fiatRate!!.multiply(feeValue).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
})",
|
||||
)
|
||||
val fee: TextReference = stringReference("0.001 ETH")
|
||||
val txUrl = "https://explorer.example/tx/0xabc"
|
||||
val timestamp = 1_700_000_000_000L
|
||||
|
||||
val result = sut.createSuccessState(
|
||||
uiState = baseStateHolder(),
|
||||
dataState = dataState,
|
||||
appCurrency = appCurrency,
|
||||
isAccountsMode = true,
|
||||
fee = fee,
|
||||
txUrl = txUrl,
|
||||
timestamp = timestamp,
|
||||
fee = fee,
|
||||
onExplorerClick = {},
|
||||
onShareClick = {},
|
||||
)
|
||||
|
||||
val success = requireNotNull(result.successState)
|
||||
|
|
@ -491,7 +781,7 @@ internal class SwapTransferStateBuilderTest {
|
|||
assertThat(success.shouldShowStatusButton).isFalse()
|
||||
assertThat(success.timestamp).isEqualTo(timestamp)
|
||||
assertThat(success.txUrl).isEqualTo(txUrl)
|
||||
assertThat(success.fee).isEqualTo(fee)
|
||||
assertThat(success.fee).isEqualTo(expectedFee)
|
||||
assertThat(success.providerName).isEqualTo(TextReference.EMPTY)
|
||||
assertThat(success.providerType).isEqualTo(TextReference.EMPTY)
|
||||
assertThat(success.providerIcon).isEmpty()
|
||||
|
|
@ -499,6 +789,172 @@ internal class SwapTransferStateBuilderTest {
|
|||
assertThat(success.fromTokenIconState).isEqualTo(fromIcon)
|
||||
assertThat(success.toTokenIconState).isEqualTo(toIcon)
|
||||
|
||||
// from side reflects dataState.amount, to side reflects transferState.sendingAmount
|
||||
assertThat(success.fromTokenAmount)
|
||||
.isEqualTo(stringReference(fromAmount.format { crypto(symbol = "ETH", decimals = 18) }))
|
||||
assertThat(success.toTokenAmount)
|
||||
.isEqualTo(stringReference(toAmount.format { crypto(symbol = "ETH", decimals = 18) }))
|
||||
assertThat(success.fromTokenFiatAmount).isEqualTo(
|
||||
stringReference(
|
||||
fromCurrencyStatus.status.value.fiatRate!!.multiply(fromAmount).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
),
|
||||
)
|
||||
assertThat(success.toTokenFiatAmount).isEqualTo(
|
||||
stringReference(
|
||||
toCurrencyStatus.status.value.fiatRate!!.multiply(toAmount).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
|
||||
val expectedIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
|
||||
val expectedName = portfolioAccount.accountName.toUM().value
|
||||
assertThat(success.fromTitle).isEqualTo(
|
||||
AccountTitleUM.Account(
|
||||
prefixText = resourceReference(R.string.swapping_from_account_title),
|
||||
name = expectedName,
|
||||
icon = expectedIcon,
|
||||
),
|
||||
)
|
||||
assertThat(success.toTitle).isEqualTo(
|
||||
AccountTitleUM.Account(
|
||||
prefixText = resourceReference(R.string.swapping_to_account_title),
|
||||
name = expectedName,
|
||||
icon = expectedIcon,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null fee but tangem pay withdrawal WHEN updateTransferButtonEnableState THEN swap button is enabled with no footer`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = BigDecimal("1"),
|
||||
toAmount = BigDecimal("1"),
|
||||
isAccountsMode = false,
|
||||
)
|
||||
val dataState = SwapProcessDataState()
|
||||
val uiState = baseStateHolder().copy(
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = null,
|
||||
isEnabled = false,
|
||||
mode = SwapButton.Mode.TRANSFER,
|
||||
onClick = {},
|
||||
),
|
||||
)
|
||||
|
||||
val result = sut.updateTransferButtonEnableState(
|
||||
dataState = dataState,
|
||||
transferState = transferState,
|
||||
actions = actions,
|
||||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
isTangemPayWithdrawal = true,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isTrue()
|
||||
assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER)
|
||||
// fee is null → footer is omitted, but the button stays enabled because it is a Tangem Pay withdrawal
|
||||
assertThat(result.transferFooter).isNull()
|
||||
assertThat(result.notifications).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null fee and not tangem pay withdrawal WHEN updateTransferButtonEnableState THEN swap button stays disabled`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = BigDecimal("1"),
|
||||
toAmount = BigDecimal("1"),
|
||||
isAccountsMode = false,
|
||||
)
|
||||
val dataState = SwapProcessDataState()
|
||||
val uiState = baseStateHolder().copy(
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = null,
|
||||
isEnabled = false,
|
||||
mode = SwapButton.Mode.TRANSFER,
|
||||
onClick = {},
|
||||
),
|
||||
)
|
||||
|
||||
val result = sut.updateTransferButtonEnableState(
|
||||
dataState = dataState,
|
||||
transferState = transferState,
|
||||
actions = actions,
|
||||
uiStateHolder = uiState,
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
isTangemPayWithdrawal = false,
|
||||
feeSelectorUM = null,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN transfer dataState WHEN createTangemPayWithdrawalSuccessState THEN feeless transfer success holder is built`() {
|
||||
// from amount comes from dataState.amount, to amount from transferState.sendingAmount — keep them distinct
|
||||
val fromAmount = BigDecimal("2.0")
|
||||
val toAmount = BigDecimal("1.5")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = fromAmount,
|
||||
toAmount = toAmount,
|
||||
isAccountsMode = true,
|
||||
)
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = fromCurrencyStatus,
|
||||
toSwapCurrencyStatus = toCurrencyStatus,
|
||||
amount = fromAmount.toPlainString(),
|
||||
currentTransferState = transferState,
|
||||
)
|
||||
val onExploreClick = {}
|
||||
val appCurrency = transferState.appCurrency
|
||||
// both fiat amounts use the from-currency rate (see createTangemPayWithdrawalSuccessState)
|
||||
val expectedFromFiat = stringReference(
|
||||
fromCurrencyStatus.status.value.fiatRate!!.multiply(fromAmount).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
)
|
||||
val expectedToFiat = stringReference(
|
||||
fromCurrencyStatus.status.value.fiatRate!!.multiply(toAmount).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
)
|
||||
|
||||
val before = System.currentTimeMillis()
|
||||
val result = sut.createTangemPayWithdrawalSuccessState(
|
||||
uiState = baseStateHolder(),
|
||||
dataState = dataState,
|
||||
fee = null,
|
||||
onExploreClick = onExploreClick,
|
||||
onShareClick = {},
|
||||
)
|
||||
val after = System.currentTimeMillis()
|
||||
|
||||
val success = requireNotNull(result.successState)
|
||||
assertThat(success.isTransferMode).isTrue()
|
||||
assertThat(success.shouldShowStatusButton).isFalse()
|
||||
assertThat(success.fee).isNull()
|
||||
assertThat(success.txUrl).isEmpty()
|
||||
assertThat(success.providerName).isEqualTo(stringReference(""))
|
||||
assertThat(success.providerType).isEqualTo(stringReference(""))
|
||||
assertThat(success.providerIcon).isEmpty()
|
||||
assertThat(success.rate).isEqualTo(TextReference.EMPTY)
|
||||
assertThat(success.timestamp).isAtLeast(before)
|
||||
assertThat(success.timestamp).isAtMost(after)
|
||||
assertThat(success.fromTokenAmount).isEqualTo(stringReference(fromAmount.toString()))
|
||||
assertThat(success.toTokenAmount).isEqualTo(stringReference(toAmount.toString()))
|
||||
assertThat(success.fromTokenFiatAmount).isEqualTo(expectedFromFiat)
|
||||
assertThat(success.toTokenFiatAmount).isEqualTo(expectedToFiat)
|
||||
assertThat(success.fromTokenIconState).isEqualTo(fromIcon)
|
||||
assertThat(success.toTokenIconState).isEqualTo(toIcon)
|
||||
assertThat((success.navigationUM as NavigationUM.Content).primaryButton.onClick).isEqualTo(onExploreClick)
|
||||
|
||||
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
|
||||
val expectedIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
|
||||
val expectedName = portfolioAccount.accountName.toUM().value
|
||||
|
|
@ -524,8 +980,12 @@ internal class SwapTransferStateBuilderTest {
|
|||
) {
|
||||
val sendCard = result.sendCardData as SwapCardState.SwapCardData
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
assertThat(sendCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue)
|
||||
assertThat(receiveCard.amountTextFieldValue).isEqualTo(initialAmountTextFieldValue)
|
||||
// The "from" card preserves the user's typed value; the receive card shows the (possibly
|
||||
// fee-reduced) sendingAmount formatted with the receive token's decimals.
|
||||
assertThat(sendCard.amountField?.value).isEqualTo(initialAmountValue)
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(
|
||||
transferState.sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
|
||||
)
|
||||
assertThat(sendCard.currencyIconState).isEqualTo(fromIcon)
|
||||
assertThat(receiveCard.currencyIconState).isEqualTo(toIcon)
|
||||
assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden)
|
||||
|
|
@ -555,6 +1015,9 @@ internal class SwapTransferStateBuilderTest {
|
|||
toAmount: BigDecimal,
|
||||
isAccountsMode: Boolean,
|
||||
isInsufficientBalance: Boolean = false,
|
||||
isFeeCoverage: Boolean = false,
|
||||
isSendingAmountLoading: Boolean = false,
|
||||
isAmountSubtractAvailable: Boolean = false,
|
||||
): SwapState.Transfer {
|
||||
val fromInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(value = fromAmount, decimals = fromCurrencyStatus.currency.decimals),
|
||||
|
|
@ -570,19 +1033,36 @@ internal class SwapTransferStateBuilderTest {
|
|||
userWallet = coldWallet,
|
||||
fromTokenInfo = fromInfo,
|
||||
toTokenInfo = toInfo,
|
||||
cryptoCurrencyWarning = null,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
appCurrency = AppCurrency.Default,
|
||||
isBalanceHidden = false,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isFeeCoverage = false,
|
||||
sendingAmount = fromAmount,
|
||||
isFeeCoverage = isFeeCoverage,
|
||||
sendingAmount = toAmount,
|
||||
tronFeeNotificationShowCount = 0,
|
||||
isAmountSubtractAvailable = isAmountSubtractAvailable,
|
||||
isSendingAmountLoading = isSendingAmountLoading,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN swap title state WHEN updateTransferTitle THEN only titleId becomes transfer`() {
|
||||
// Arrange
|
||||
val uiState = baseStateHolder()
|
||||
|
||||
// Act
|
||||
val result = sut.updateTransferTitle(uiState)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(uiState.copy(titleId = R.string.common_transfer))
|
||||
}
|
||||
|
||||
private fun baseStateHolder(): SwapStateHolder = SwapStateHolder(
|
||||
titleId = R.string.common_swap,
|
||||
sendCardData = SwapCardState.SwapCardData(
|
||||
appCurrency = AppCurrency.Default,
|
||||
type = TransactionCardType.Inputtable(
|
||||
onAmountChanged = {},
|
||||
onFocusChanged = {},
|
||||
inputError = TransactionCardType.InputError.Empty,
|
||||
accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
|
||||
|
|
@ -591,7 +1071,7 @@ internal class SwapTransferStateBuilderTest {
|
|||
currencyIconState = fromIcon,
|
||||
tokenSymbol = stringReference(""),
|
||||
amountEquivalent = TextReference.EMPTY,
|
||||
amountTextFieldValue = initialAmountTextFieldValue,
|
||||
amountField = initialAmountField,
|
||||
balance = TextReference.EMPTY,
|
||||
isBalanceHidden = false,
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue