From 83446489ba9d94f4b2cb736156859c168fffcd56 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 1 Jun 2026 14:44:48 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../feature/swap/models/SwapStateHolder.kt | 5 +- .../tangem/feature/swap/ui/StateBuilder.kt | 46 +++++++- .../feature/swap/ui/SwapScreenContent.kt | 71 +++++------- .../feature/swap/StateBuilderPairsTest.kt | 105 ++++++++++++++++++ 4 files changed, 179 insertions(+), 48 deletions(-) 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 ff8dfd3624..24b806a74b 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 @@ -6,9 +6,9 @@ import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.ui.account.AccountTitleUM 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.swap.models.PredefinedPercentAmount 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 @@ -30,10 +30,10 @@ internal data class SwapStateHolder( val bottomSheetConfig: TangemBottomSheetConfig? = null, val swapButton: SwapButton, val shouldShowMaxAmount: Boolean, + val predefinedButtons: ImmutableList = persistentListOf(), val tosState: TosState? = null, val swapUIMode: SwapUIMode = SwapUIMode.Detailed, val shouldShowAbMenu: Boolean = false, - val isPredefinedButtonsEnabled: Boolean = false, val transferFooter: TextReference? = null, @@ -43,7 +43,6 @@ internal data class SwapStateHolder( val onSelectTokenClick: ((TokenSelectionDirection) -> Unit), val onSuccess: (() -> Unit), val onMaxAmountSelected: (() -> Unit)? = null, - val onPredefinedPercentSelected: ((PredefinedPercentAmount) -> Unit)? = null, val onShowPermissionBottomSheet: () -> Unit = {}, val onSwapUIModeChange: (SwapUIMode) -> Unit = {}, val onSwapTypeMenuOpened: () -> 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 7236b2fa02..7991737bec 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 @@ -12,6 +12,7 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.buttons.predefined.PredefinedPercentButtonUM import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat @@ -26,6 +27,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet +import com.tangem.domain.swap.models.PredefinedPercentAmount import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork @@ -97,7 +99,6 @@ internal class StateBuilder( onBackClicked = actions.onBackClicked, onChangeCardsClicked = actions.onChangeCardsClicked, onMaxAmountSelected = actions.onMaxAmountSelected, - onPredefinedPercentSelected = actions.onPredefinedPercentSelected, changeCardsButtonState = ChangeCardsButtonState.DISABLED, onShowPermissionBottomSheet = actions.openPermissionBottomSheet, onSelectTokenClick = actions.onSelectTokenClick, @@ -110,7 +111,6 @@ internal class StateBuilder( onSwapUIModeChange = actions.onSwapUIModeChange, onSwapTypeMenuOpened = actions.onSwapTypeMenuOpened, shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled, - isPredefinedButtonsEnabled = swapFeatureToggles.isSwapPredefinedButtonsEnabled, ) } @@ -142,6 +142,10 @@ internal class StateBuilder( onClick = { }, ), shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency), + predefinedButtons = createPredefinedButtons( + fromSwapCurrencyStatus?.currency, + toSwapCurrencyStatus?.currency, + ), changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty, @@ -215,6 +219,7 @@ internal class StateBuilder( changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, priceImpact = PriceImpact.Empty, shouldShowMaxAmount = shouldShowMaxAmount(fromCurrency, toCurrency), + predefinedButtons = createPredefinedButtons(fromCurrency, toCurrency), transferFooter = null, ) } @@ -250,6 +255,10 @@ internal class StateBuilder( onClick = { }, ), shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency), + predefinedButtons = createPredefinedButtons( + fromSwapCurrencyStatus?.currency, + toSwapCurrencyStatus?.currency, + ), changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty, @@ -446,6 +455,7 @@ internal class StateBuilder( changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, priceImpact = PriceImpact.Empty, shouldShowMaxAmount = shouldShowMaxAmount(fromCurrency, toCurrency), + predefinedButtons = createPredefinedButtons(fromCurrency, toCurrency), ) } @@ -576,6 +586,7 @@ internal class StateBuilder( priceImpact = priceImpact, tosState = createTosState(swapProvider), shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency), + predefinedButtons = createPredefinedButtons(fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency), ) } @@ -611,6 +622,37 @@ internal class StateBuilder( return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id) } + /** + * Builds the predefined percent buttons once per state update (off the composition path). + * The row is gated by the feature toggle; the MAX button is included only when + * [shouldShowMaxAmount] is `true` (e.g. it is dropped for a native coin swapped within the same + * network, where spending the full balance would leave nothing for the network fee). + */ + private fun createPredefinedButtons( + fromToken: CryptoCurrency?, + toCurrency: CryptoCurrency?, + ): ImmutableList { + if (!swapFeatureToggles.isSwapPredefinedButtonsEnabled) return persistentListOf() + val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency) + return PredefinedPercentAmount.entries + .filter { it != PredefinedPercentAmount.MAX || shouldShowMaxAmount } + .map { percent -> + PredefinedPercentButtonUM( + id = percent.name, + label = percent.toLabel(), + onClick = { actions.onPredefinedPercentSelected(percent) }, + ) + } + .toImmutableList() + } + + private fun PredefinedPercentAmount.toLabel(): TextReference = when (this) { + PredefinedPercentAmount.PERCENT_25 -> stringReference("25%") + PredefinedPercentAmount.PERCENT_50 -> stringReference("50%") + PredefinedPercentAmount.PERCENT_75 -> stringReference("75%") + PredefinedPercentAmount.MAX -> resourceReference(R.string.send_max_amount) + } + private fun createTosState(swapProvider: SwapProvider): TosState { return TosState( tosLink = swapProvider.termsOfUse?.let { termsUrl -> 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 2c85020628..bd03ee7e95 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 @@ -33,17 +33,14 @@ 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.buttons.predefined.PredefinedPercentButtonUM import com.tangem.core.ui.components.buttons.predefined.PredefinedPercentButtonsRow import com.tangem.core.ui.components.notifications.Notification import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags -import com.tangem.domain.swap.models.PredefinedPercentAmount import com.tangem.feature.swap.domain.models.domain.SwapUIMode import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.* @@ -53,7 +50,6 @@ 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.collections.immutable.toImmutableList @Suppress("LongMethod") @Composable @@ -115,50 +111,39 @@ internal fun SwapScreenContent( MainButton(state = state) } - if (state.shouldShowMaxAmount && keyboard is Keyboard.Opened) { - val onPercentClick = state.onPredefinedPercentSelected - if (state.isPredefinedButtonsEnabled && onPercentClick != null) { - PredefinedPercentButtonsRow( - items = PredefinedPercentAmount.entries.map { percent -> - PredefinedPercentButtonUM( - id = percent.name, - label = percent.toLabel(), - onClick = { onPercentClick(percent) }, - ) - }.toImmutableList(), - modifier = Modifier - .align(Alignment.BottomCenter) - .imePadding(), - ) - } else { - Text( - text = stringResourceSafe(id = R.string.send_max_amount_label), - style = TangemTheme.typography.button, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .align(Alignment.BottomCenter) - .imePadding() - .fillMaxWidth() - .background(TangemTheme.colors.button.secondary) - .clickable { state.onMaxAmountSelected?.invoke() } - .padding( - horizontal = TangemTheme.dimens.spacing14, - vertical = TangemTheme.dimens.spacing16, - ), - textAlign = TextAlign.Start, - ) + if (keyboard is Keyboard.Opened) { + when { + state.predefinedButtons.isNotEmpty() -> { + PredefinedPercentButtonsRow( + items = state.predefinedButtons, + modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding(), + ) + } + state.shouldShowMaxAmount -> { + Text( + text = stringResourceSafe(id = R.string.send_max_amount_label), + style = TangemTheme.typography.button, + color = TangemTheme.colors.text.primary1, + modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding() + .fillMaxWidth() + .background(TangemTheme.colors.button.secondary) + .clickable { state.onMaxAmountSelected?.invoke() } + .padding( + horizontal = TangemTheme.dimens.spacing14, + vertical = TangemTheme.dimens.spacing16, + ), + textAlign = TextAlign.Start, + ) + } } } } } -private fun PredefinedPercentAmount.toLabel() = when (this) { - PredefinedPercentAmount.PERCENT_25 -> stringReference("25%") - PredefinedPercentAmount.PERCENT_50 -> stringReference("50%") - PredefinedPercentAmount.PERCENT_75 -> stringReference("75%") - PredefinedPercentAmount.MAX -> resourceReference(R.string.send_max_amount) -} - @Composable private fun MainInfo(state: SwapStateHolder) { ConstraintLayout( diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt index ba6ee3e353..ca87599216 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt @@ -4,6 +4,12 @@ import com.google.common.truth.Truth.assertThat import com.tangem.common.routing.AppRouter import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.swap.models.PredefinedPercentAmount +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork @@ -381,4 +387,103 @@ internal class StateBuilderPairsTest { toSwapCurrencyStatus = toStatus, ) } + + // region predefined buttons visibility + + @Nested + inner class PredefinedButtonsVisibility { + + @Test + fun `GIVEN toggle on and native coin within same network WHEN updateCurrenciesState THEN MAX button is dropped but percents stay`() { + every { swapFeatureToggles.isSwapPredefinedButtonsEnabled } returns true + val baseState = buildReadyState(coldWallet) + val networkId: Network.ID = mockk(relaxed = true) + val fromStatus = buildCoinSwapCurrencyStatus(coldWallet, networkId) + val toStatus = buildCoinSwapCurrencyStatus(coldWallet, networkId) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + shouldResetAmount = false, + ) + + // Legacy MAX text stays gated by shouldShowMaxAmount ([REDACTED_TASK_KEY] behavior preserved)... + assertThat(result.shouldShowMaxAmount).isFalse() + // ...and MAX is also dropped from the predefined row, but the percents remain. + assertThat(result.predefinedButtons.map { it.id }).containsExactly( + PredefinedPercentAmount.PERCENT_25.name, + PredefinedPercentAmount.PERCENT_50.name, + PredefinedPercentAmount.PERCENT_75.name, + ).inOrder() + } + + @Test + fun `GIVEN toggle on and non-coin WHEN updateCurrenciesState THEN all percents including MAX are built`() { + every { swapFeatureToggles.isSwapPredefinedButtonsEnabled } returns true + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + shouldResetAmount = false, + ) + + assertThat(result.shouldShowMaxAmount).isTrue() + assertThat(result.predefinedButtons.map { it.id }) + .containsExactlyElementsIn(PredefinedPercentAmount.entries.map { it.name }) + .inOrder() + } + + @Test + fun `GIVEN toggle off WHEN updateCurrenciesState THEN no predefined buttons are built`() { + every { swapFeatureToggles.isSwapPredefinedButtonsEnabled } returns false + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + shouldResetAmount = false, + ) + + assertThat(result.predefinedButtons).isEmpty() + } + + @Test + fun `WHEN createInitialLoadingState THEN no predefined buttons are built`() { + val result = sut.createInitialLoadingState() + + assertThat(result.predefinedButtons).isEmpty() + } + } + + // endregion + + private fun buildCoinSwapCurrencyStatus(userWallet: UserWallet, networkId: Network.ID): SwapCurrencyStatus { + val account = Account.CryptoPortfolio.createMainAccount(userWallet.walletId) + val coin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { decimals } returns 18 + every { symbol } returns "ETH" + every { network } returns mockk(relaxed = true) { + every { id } returns networkId + } + } + val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { amount } returns java.math.BigDecimal("1.0") + } + return SwapCurrencyStatus( + userWallet = userWallet, + status = CryptoCurrencyStatus(currency = coin, value = statusValue), + account = account, + ) + } } \ No newline at end of file