Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-25 14:11:41 +01:00
parent 23eeb6de60
commit b13925b77a
9 changed files with 252 additions and 5 deletions

View file

@ -60,7 +60,7 @@
"version": "undefined"
},
{
"name": "SWAP_SWITCH_TO_TRANSFER_ENABLED",
"name": "AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED",
"version": "undefined"
},
{

View file

@ -51,7 +51,6 @@ internal class FeatureTogglesNamingConventionTest {
"STAKING_ETH_ENABLED",
"SWAP_AB_ENABLED",
"SWAP_INTEGRATED_APPROVE",
"SWAP_SWITCH_TO_TRANSFER_ENABLED",
"USEDESK_ENABLED",
"VIRTUAL_ACCOUNTS_ENABLED",
"VISA_ONBOARDING_ENABLED",

View file

@ -10,7 +10,7 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
) : SwapFeatureToggles {
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_SWITCH_TO_TRANSFER_ENABLED,
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
)
override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled(

View file

@ -763,6 +763,7 @@ internal class SwapModel @Inject constructor(
) as? SwapState.Transfer ?: currentTransferState
dataState = dataState.copy(currentTransferState = refreshed)
uiState = swapTransferStateBuilder.updateTransferButtonEnableState(
dataState = dataState,
transferState = refreshed,
actions = actions,
uiStateHolder = uiState,
@ -1319,6 +1320,11 @@ internal class SwapModel @Inject constructor(
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = null,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
},

View file

@ -33,6 +33,8 @@ internal data class SwapStateHolder(
val swapUIMode: SwapUIMode = SwapUIMode.Detailed,
val shouldShowAbMenu: Boolean = false,
val transferFooter: TextReference? = null,
val onRefresh: () -> Unit,
val onBackClicked: () -> Unit,
val onChangeCardsClicked: () -> Unit,

View file

@ -212,6 +212,7 @@ internal class StateBuilder(
changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS,
priceImpact = PriceImpact.Empty,
shouldShowMaxAmount = shouldShowMaxAmount(fromCurrency, toCurrency),
transferFooter = null,
)
}
@ -778,6 +779,7 @@ internal class StateBuilder(
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
providerState = ProviderState.Empty(),
priceImpact = PriceImpact.Empty,
transferFooter = null,
)
}

View file

@ -30,9 +30,11 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import com.tangem.common.ui.footers.SendingText
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
@ -97,6 +99,13 @@ internal fun SwapScreenContent(
.padding(top = TangemTheme.dimens.spacing16),
)
}
if (state.transferFooter != null) {
TransferFooter(
textReference = state.transferFooter,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16),
)
}
MainButton(state = state)
}
@ -187,6 +196,14 @@ private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) {
)
}
@Composable
private fun TransferFooter(textReference: TextReference, modifier: Modifier = Modifier) {
SendingText(
modifier = modifier,
footerText = textReference,
)
}
@Composable
private fun getAnnotatedStringForLegalsWithClick(
tos: LegalState?,

View file

@ -12,6 +12,7 @@ import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
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
@ -20,6 +21,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.model.SwapProcessDataState
@ -27,13 +29,17 @@ 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.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.ImmutableList
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LargeClass")
internal class SwapTransferStateBuilder @Inject constructor(
private val notificationsFactory: SwapTransferNotificationsFactory,
private val isFeeApproximateUseCase: IsFeeApproximateUseCase,
) {
private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -204,7 +210,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
}
}
@Suppress("LongParameterList")
fun updateTransferButtonEnableState(
dataState: SwapProcessDataState,
transferState: SwapState.Transfer,
actions: UiActions,
uiStateHolder: SwapStateHolder,
@ -223,6 +231,12 @@ internal class SwapTransferStateBuilder @Inject constructor(
swapButton = uiStateHolder.swapButton.copy(
isEnabled = getTransferButtonEnabled(notifications, fee),
),
transferFooter = getSendingFooterText(
dataState = dataState,
fee = fee,
tokenSwapInfo = transferState.fromTokenInfo,
appCurrency = transferState.appCurrency,
),
)
}
@ -238,6 +252,58 @@ internal class SwapTransferStateBuilder @Inject constructor(
}
}
private fun getSendingFooterText(
dataState: SwapProcessDataState,
fee: Fee?,
tokenSwapInfo: TokenSwapInfo,
appCurrency: AppCurrency,
): TextReference? {
if (fee == null) return null
val fiatAmountValue = tokenSwapInfo.amountFiat
val status = dataState.fromSwapCurrencyStatus?.status ?: return null
val fiatFeeValue = fee.amount.value
val isFeeConvertibleToFiat = status.currency.network.hasFiatFeeRate
val fiatSendingValue = if (isFeeConvertibleToFiat) {
fiatFeeValue?.let { fiatAmountValue.plus(it) }
} else {
fiatAmountValue
}
val fiatSending = fiatSendingValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
val networkId = status.currency.network.id
val fiatFee = formatFooterFiatFee(
amount = fee.amount.copy(value = fiatFeeValue),
isFeeConvertibleToFiat = isFeeConvertibleToFiat,
isFeeApproximate = isFeeApproximateUseCase(networkId = networkId, amountType = fee.amount.type),
appCurrency = appCurrency,
)
return if (fee is Fee.Tron) {
getTronTokenFeeSendingText(
fee = fee,
fiatFee = fiatFee,
fiatSending = stringReference(fiatSending),
)
} else {
resourceReference(
id = if (isFeeConvertibleToFiat) {
com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description
} else {
com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description_no_fiat_fee
},
formatArgs = wrappedList(fiatSending, fiatFee),
)
}
}
fun createTransferInProgressState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(
@ -256,6 +322,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
txUrl: String,
timestamp: Long,
fee: TextReference?,
onExplorerClick: () -> Unit,
): SwapStateHolder {
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
@ -301,7 +368,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
toTokenFiatAmount = toFiatAmount,
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
onExploreButtonClick = {},
onExploreButtonClick = onExplorerClick,
onStatusButtonClick = {},
),
)

View file

@ -3,6 +3,7 @@ package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
@ -12,11 +13,18 @@ import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase
import com.tangem.feature.swap.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.PriceImpact
@ -51,7 +59,13 @@ internal class SwapTransferStateBuilderTest {
)
} returns persistentListOf()
}
private val sut = SwapTransferStateBuilder(notificationsFactory = notificationsFactory)
private val isFeeApproximateUseCase: IsFeeApproximateUseCase = mockk(relaxed = true) {
every { invoke(networkId = any(), amountType = any()) } returns false
}
private val sut = SwapTransferStateBuilder(
notificationsFactory = notificationsFactory,
isFeeApproximateUseCase = isFeeApproximateUseCase,
)
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
@ -277,6 +291,7 @@ internal class SwapTransferStateBuilderTest {
isAccountsMode = false,
)
val fee: Fee = mockk(relaxed = true)
val dataState = SwapProcessDataState()
val uiState = baseStateHolder().copy(
swapButton = SwapButton(
walletInteractionIcon = null,
@ -296,6 +311,7 @@ internal class SwapTransferStateBuilderTest {
} returns persistentListOf()
val result = sut.updateTransferButtonEnableState(
dataState = dataState,
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
@ -317,6 +333,135 @@ internal class SwapTransferStateBuilderTest {
}
}
@Test
fun `GIVEN Tron fee WHEN updateTransferButtonEnableState THEN transferFooter uses Tron token fee sending text`() =
runTest {
val fromAmount = BigDecimal("1")
val transferState = buildTransferState(
fromAmount = fromAmount,
toAmount = fromAmount,
isAccountsMode = false,
)
val statusWithNetwork = buildStatusWithNetwork(hasFiatFeeRate = false)
val dataState = SwapProcessDataState(fromSwapCurrencyStatus = statusWithNetwork)
val fee = Fee.Tron(
amount = Amount(currencySymbol = "TRX", value = BigDecimal("0.5"), decimals = 6),
remainingEnergy = 1000L,
feeEnergy = 100L,
)
val uiState = baseStateHolder()
val result = sut.updateTransferButtonEnableState(
dataState = dataState,
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
)
assertThat(result.transferFooter).isInstanceOf(TextReference.Combined::class.java)
val refs = (result.transferFooter as TextReference.Combined).refs.data
assertThat(refs).hasSize(3)
assertThat(refs[0]).isInstanceOf(TextReference.Res::class.java)
assertThat((refs[0] as TextReference.Res).id)
.isEqualTo(com.tangem.features.send.v2.api.R.string.send_summary_transaction_description_prefix)
assertThat(refs[2]).isInstanceOf(TextReference.Res::class.java)
assertThat((refs[2] as TextReference.Res).id)
.isEqualTo(com.tangem.features.send.v2.api.R.string.send_summary_transaction_description_suffix_fee_covered)
}
@Test
fun `GIVEN non-Tron fee and fiat-convertible network WHEN updateTransferButtonEnableState THEN transferFooter uses fiat fee description`() =
runTest {
val fromAmount = BigDecimal("1")
val transferState = buildTransferState(
fromAmount = fromAmount,
toAmount = fromAmount,
isAccountsMode = false,
)
val statusWithNetwork = buildStatusWithNetwork(hasFiatFeeRate = true)
val dataState = SwapProcessDataState(fromSwapCurrencyStatus = statusWithNetwork)
val feeValue = BigDecimal("0.001")
val fee = Fee.Common(
amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18),
)
val uiState = baseStateHolder()
val appCurrency = transferState.appCurrency
val expectedFiatSending = (fromAmount * QUOTE).plus(feeValue).format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
val expectedFiatFee = feeValue.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
val result = sut.updateTransferButtonEnableState(
dataState = dataState,
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
)
assertThat(result.transferFooter).isEqualTo(
resourceReference(
id = com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description,
formatArgs = wrappedList(expectedFiatSending, expectedFiatFee),
),
)
}
@Test
fun `GIVEN non-Tron fee and non-fiat-convertible network WHEN updateTransferButtonEnableState THEN transferFooter uses no-fiat-fee description`() =
runTest {
val fromAmount = BigDecimal("1")
val transferState = buildTransferState(
fromAmount = fromAmount,
toAmount = fromAmount,
isAccountsMode = false,
)
val statusWithNetwork = buildStatusWithNetwork(hasFiatFeeRate = false)
val dataState = SwapProcessDataState(fromSwapCurrencyStatus = statusWithNetwork)
val feeValue = BigDecimal("0.001")
val feeAmount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18)
val fee = Fee.Common(amount = feeAmount)
val uiState = baseStateHolder()
val appCurrency = transferState.appCurrency
val expectedFiatSending = (fromAmount * QUOTE).format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
val expectedFiatFee = feeValue.format {
crypto(decimals = feeAmount.decimals, symbol = feeAmount.currencySymbol)
.fee(canBeLower = false)
}
val result = sut.updateTransferButtonEnableState(
dataState = dataState,
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
)
assertThat(result.transferFooter).isEqualTo(
resourceReference(
id = com.tangem.features.send.v2.impl.R.string.send_summary_transaction_description_no_fiat_fee,
formatArgs = wrappedList(expectedFiatSending, expectedFiatFee),
),
)
}
@Test
fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
@ -338,6 +483,7 @@ internal class SwapTransferStateBuilderTest {
txUrl = txUrl,
timestamp = timestamp,
fee = fee,
onExplorerClick = {},
)
val success = requireNotNull(result.successState)
@ -396,6 +542,14 @@ internal class SwapTransferStateBuilderTest {
)
}
private fun buildStatusWithNetwork(hasFiatFeeRate: Boolean): SwapCurrencyStatus {
val networkId: Network.ID = mockk(relaxed = true)
val status = buildSwapCurrencyStatus(coldWallet)
every { status.status.currency.network.id } returns networkId
every { status.status.currency.network.hasFiatFeeRate } returns hasFiatFeeRate
return status
}
private fun buildTransferState(
fromAmount: BigDecimal,
toAmount: BigDecimal,