Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-07 13:19:50 +03:00
commit 383f1abc4f
2127 changed files with 89632 additions and 12987 deletions

View file

@ -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)
}

View file

@ -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

View file

@ -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
}

View file

@ -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) {

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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,

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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),
)
}
}

View file

@ -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 = {},
)
}

View file

@ -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),
)
}
}
}

View file

@ -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(

View file

@ -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)

View file

@ -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(

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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(

View file

@ -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"

View file

@ -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),

View file

@ -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) {

View file

@ -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()

View file

@ -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)),

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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,
)
}

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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 }
}
}
}

View file

@ -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 {

View file

@ -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()) }
}
}

View file

@ -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() }
}
}

View file

@ -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))
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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"
}
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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
}
}

View file

@ -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,
),