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

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