Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-26 00:26:27 +04:00
parent ff2559df3e
commit 0bbc78f821
15 changed files with 368 additions and 17 deletions

View file

@ -108,4 +108,8 @@ internal object SwapDomainModule {
swapErrorResolver = swapErrorResolver,
)
}
@Provides
@Singleton
fun provideCalculateAmountUseCase(): CalculateAmountUseCase = CalculateAmountUseCase()
}

View file

@ -90,5 +90,9 @@
{
"name": "AND_15103_SWAP_RATE_EXPERIENCE_ENABLED",
"version": "undefined"
},
{
"name": "AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED",
"version": "undefined"
}
]

View file

@ -0,0 +1,101 @@
package com.tangem.core.ui.components.buttons.predefined
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.key
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.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class PredefinedPercentButtonUM(
val id: String,
val label: TextReference,
val onClick: () -> Unit,
)
@Composable
fun PredefinedPercentButtonsRow(items: ImmutableList<PredefinedPercentButtonUM>, modifier: Modifier = Modifier) {
if (items.isEmpty()) return
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors.button.secondary)
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 10.dp),
) {
items.fastForEach { item ->
key(item.id) {
PercentPill(
item = item,
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun PercentPill(item: PredefinedPercentButtonUM, modifier: Modifier = Modifier) {
Text(
text = item.label.resolveReference(),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
modifier = modifier
.testTag(item.id)
.clip(RoundedCornerShape(16.dp))
.height(24.dp)
.background(TangemTheme.colors.field.primary)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = item.onClick,
)
.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun PredefinedPercentButtonsRow_Preview() {
TangemThemePreview {
PredefinedPercentButtonsRow(
items = persistentListOf(
PredefinedPercentButtonUM(id = "25", label = stringReference("25%"), onClick = {}),
PredefinedPercentButtonUM(id = "50", label = stringReference("50%"), onClick = {}),
PredefinedPercentButtonUM(id = "75", label = stringReference("75%"), onClick = {}),
PredefinedPercentButtonUM(id = "max", label = stringReference("Max"), onClick = {}),
),
)
}
}
// endregion

View file

@ -30,4 +30,8 @@ dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.jodatime)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
}

View file

@ -0,0 +1,10 @@
package com.tangem.domain.swap.models
import java.math.BigDecimal
enum class PredefinedPercentAmount(val percent: BigDecimal) {
PERCENT_25(BigDecimal("0.25")),
PERCENT_50(BigDecimal("0.50")),
PERCENT_75(BigDecimal("0.75")),
MAX(BigDecimal.ONE),
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.swap.usecase
import com.tangem.domain.swap.models.PredefinedPercentAmount
import java.math.BigDecimal
import java.math.RoundingMode
class CalculateAmountUseCase {
operator fun invoke(balance: BigDecimal, decimals: Int, percent: PredefinedPercentAmount): BigDecimal {
return balance
.multiply(percent.percent)
.setScale(decimals, RoundingMode.DOWN)
}
}

View file

@ -0,0 +1,151 @@
package com.tangem.domain.swap.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.swap.models.PredefinedPercentAmount
import org.junit.Test
import java.math.BigDecimal
class CalculateAmountUseCaseTest {
private val useCase = CalculateAmountUseCase()
@Test
fun `GIVEN balance and PERCENT_25 WHEN invoke THEN return one quarter of balance`() {
val balance = BigDecimal("100")
val decimals = 2
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_25,
)
assertThat(result).isEqualTo(BigDecimal("25.00"))
}
@Test
fun `GIVEN balance and PERCENT_50 WHEN invoke THEN return half of balance`() {
val balance = BigDecimal("100")
val decimals = 2
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_50,
)
assertThat(result).isEqualTo(BigDecimal("50.00"))
}
@Test
fun `GIVEN balance and PERCENT_75 WHEN invoke THEN return three quarters of balance`() {
val balance = BigDecimal("100")
val decimals = 2
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_75,
)
assertThat(result).isEqualTo(BigDecimal("75.00"))
}
@Test
fun `GIVEN balance and MAX WHEN invoke THEN return full balance`() {
val balance = BigDecimal("100")
val decimals = 2
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.MAX,
)
assertThat(result).isEqualTo(BigDecimal("100.00"))
}
@Test
fun `GIVEN zero balance WHEN invoke THEN return zero with decimals scale`() {
val balance = BigDecimal.ZERO
val decimals = 6
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_50,
)
assertThat(result).isEqualTo(BigDecimal("0.000000"))
}
@Test
fun `GIVEN balance with more precision than decimals WHEN invoke THEN truncate result with rounding down`() {
val balance = BigDecimal("1.999999999999999999")
val decimals = 6
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_25,
)
assertThat(result).isEqualTo(BigDecimal("0.499999"))
}
@Test
fun `GIVEN fractional percent product WHEN invoke THEN round down to decimals scale`() {
val balance = BigDecimal("1")
val decimals = 1
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_75,
)
assertThat(result).isEqualTo(BigDecimal("0.7"))
}
@Test
fun `GIVEN zero decimals WHEN invoke THEN return integer value rounded down`() {
val balance = BigDecimal("9")
val decimals = 0
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_75,
)
assertThat(result).isEqualTo(BigDecimal("6"))
}
@Test
fun `GIVEN high-precision balance and MAX WHEN invoke THEN preserve balance truncated to decimals`() {
val balance = BigDecimal("12.3456789012345678")
val decimals = 8
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.MAX,
)
assertThat(result).isEqualTo(BigDecimal("12.34567890"))
}
@Test
fun `GIVEN large balance and PERCENT_50 WHEN invoke THEN return correctly scaled half`() {
val balance = BigDecimal("123456789.987654321")
val decimals = 4
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_50,
)
assertThat(result).isEqualTo(BigDecimal("61728394.9938"))
}
}

View file

@ -6,4 +6,5 @@ interface SwapFeatureToggles {
val isSwapAbEnabled: Boolean
val isSwapProviderFilterEnabled: Boolean
val isSwapRateExperienceEnabled: Boolean
val isSwapPredefinedButtonsEnabled: Boolean
}

View file

@ -28,4 +28,8 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
override val isSwapRateExperienceEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15103_SWAP_RATE_EXPERIENCE_ENABLED,
)
override val isSwapPredefinedButtonsEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED,
)
}

View file

@ -62,7 +62,9 @@ import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.stories.ShouldShowStoriesUseCase
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.usecase.CalculateAmountUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
@ -162,6 +164,7 @@ internal class SwapModel @Inject constructor(
swapFeatureToggles: SwapFeatureToggles,
private val getSwapUiModeUseCase: GetSwapUiModeUseCase,
private val setSwapUiModeUseCase: SetSwapUiModeUseCase,
private val calculateAmountUseCase: CalculateAmountUseCase,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -1514,6 +1517,25 @@ internal class SwapModel @Inject constructor(
}
}
private fun onPredefinedPercentSelected(percent: PredefinedPercentAmount) {
if (percent == PredefinedPercentAmount.MAX) {
onMaxAmountClicked()
return
}
val fromCurrency = dataState.fromSwapCurrencyStatus ?: return
val newValue = calculateAmountUseCase(
balance = fromCurrency.status.value.amount ?: BigDecimal.ZERO,
decimals = fromCurrency.status.currency.decimals,
percent = percent,
)
onAmountChanged(
SwapAmount(
value = newValue,
decimals = fromCurrency.status.currency.decimals,
).formatToUIRepresentation(),
)
}
private fun onReduceAmountClicked(newAmount: SwapAmount, reduceBalanceBy: BigDecimal = BigDecimal.ZERO) {
onAmountChanged(
value = newAmount.formatToUIRepresentation(),
@ -1658,6 +1680,7 @@ internal class SwapModel @Inject constructor(
}
},
onMaxAmountSelected = ::onMaxAmountClicked,
onPredefinedPercentSelected = ::onPredefinedPercentSelected,
onReduceToAmount = ::onReduceAmountClicked,
onReduceByAmount = ::onReduceAmountClicked,
openPermissionBottomSheet = {

View file

@ -8,6 +8,7 @@ import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
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
@ -32,6 +33,7 @@ internal data class SwapStateHolder(
val tosState: TosState? = null,
val swapUIMode: SwapUIMode = SwapUIMode.Detailed,
val shouldShowAbMenu: Boolean = false,
val isPredefinedButtonsEnabled: Boolean = false,
val transferFooter: TextReference? = null,
@ -41,6 +43,7 @@ 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 = {},
)

View file

@ -2,6 +2,7 @@ package com.tangem.feature.swap.models
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.SwapUIMode
import java.math.BigDecimal
@ -14,6 +15,7 @@ internal data class UiActions(
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
val onMaxAmountSelected: () -> Unit,
val onPredefinedPercentSelected: (PredefinedPercentAmount) -> Unit,
val onReduceToAmount: (SwapAmount) -> Unit,
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
val openPermissionBottomSheet: () -> Unit,

View file

@ -97,6 +97,7 @@ internal class StateBuilder(
onBackClicked = actions.onBackClicked,
onChangeCardsClicked = actions.onChangeCardsClicked,
onMaxAmountSelected = actions.onMaxAmountSelected,
onPredefinedPercentSelected = actions.onPredefinedPercentSelected,
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
onSelectTokenClick = actions.onSelectTokenClick,
@ -108,6 +109,7 @@ internal class StateBuilder(
swapUIMode = swapUIMode,
onSwapUIModeChange = actions.onSwapUIModeChange,
shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled,
isPredefinedButtonsEnabled = swapFeatureToggles.isSwapPredefinedButtonsEnabled,
)
}

View file

@ -33,13 +33,17 @@ 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.*
@ -49,6 +53,7 @@ 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
@ -111,26 +116,49 @@ internal fun SwapScreenContent(
}
if (state.shouldShowMaxAmount && keyboard is Keyboard.Opened) {
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,
)
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,
)
}
}
}
}
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

@ -52,7 +52,7 @@ internal class StateBuilderInitialStateTest {
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
swapFeatureToggles = swapFeatureToggles,
appRouter = appRouter
appRouter = appRouter,
)
}