From b13925b77ac38ee7b918a2e01dd4d02074e963e1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 May 2026 14:11:41 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 2 +- .../FeatureTogglesNamingConventionTest.kt | 1 - .../feature/swap/DefaultSwapFeatureToggles.kt | 2 +- .../tangem/feature/swap/model/SwapModel.kt | 6 + .../feature/swap/models/SwapStateHolder.kt | 2 + .../tangem/feature/swap/ui/StateBuilder.kt | 2 + .../feature/swap/ui/SwapScreenContent.kt | 17 ++ .../ui/transfer/SwapTransferStateBuilder.kt | 69 +++++++- .../transfer/SwapTransferStateBuilderTest.kt | 156 +++++++++++++++++- 9 files changed, 252 insertions(+), 5 deletions(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 4c88446e0a..0f2e246e52 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -60,7 +60,7 @@ "version": "undefined" }, { - "name": "SWAP_SWITCH_TO_TRANSFER_ENABLED", + "name": "AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED", "version": "undefined" }, { diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt index 949c85c8ac..fa6ba51dee 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt @@ -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", diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index 34a653d071..4fe1408cad 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -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( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 36d51df8be..0b9157d56e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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) }, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index df7055d840..bc1773ac51 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index ce723c9100..0dd7312e4c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 0e074cbef4..e0a98a1fb0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -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?, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 451601f4d6..e9cd7a543b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -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 = {}, ), ) diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index 14bcd8fdea..f9187e54f5 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -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,