Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 14:44:48 +04:00
parent 2415a8a37b
commit 83446489ba
4 changed files with 179 additions and 48 deletions

View file

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

View file

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

View file

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

View file

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