From a44887bce4e7d16763ac09161552994941ecfd3d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 9 Jun 2026 14:20:17 +0800 Subject: [PATCH 1/3] fix(trade): limit decimal places for trade inputs --- .../ui/home/web3/components/InputArea.kt | 11 +++- .../ui/home/web3/components/PriceInputArea.kt | 2 + .../ui/home/web3/trade/InputTextField.kt | 65 +++++++++++++++++-- .../ui/home/web3/trade/LimitOrderContent.kt | 47 ++++++++------ .../android/ui/home/web3/trade/SwapContent.kt | 29 +++++---- .../home/web3/trade/perps/OpenPositionPage.kt | 20 ++++-- .../PerpsAddBottomSheetDialogFragment.kt | 14 +++- .../ui/home/web3/trade/perps/PerpsFormat.kt | 1 + .../PerpsTpSlBottomSheetDialogFragment.kt | 7 +- 9 files changed, 148 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/InputArea.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/InputArea.kt index c9852105b6..13ae090afa 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/InputArea.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/InputArea.kt @@ -60,6 +60,7 @@ fun InputArea( displayBalanceOverride: String? = null, bottomCompose: (@Composable () -> Unit)? = null, inlineEndCompose: (@Composable () -> Unit)? = null, + maxDecimalPlaces: Int? = null, ) { val viewModel = hiltViewModel() val balance = if (token == null) { @@ -92,7 +93,15 @@ fun InputArea( } } Box(modifier = Modifier.height(10.dp)) - InputContent(token = token, text = text, selectClick = selectClick, onInputChanged = onInputChanged, readOnly = readOnly, inlineEndCompose = inlineEndCompose) + InputContent( + token = token, + text = text, + selectClick = selectClick, + onInputChanged = onInputChanged, + readOnly = readOnly, + inlineEndCompose = inlineEndCompose, + maxDecimalPlaces = maxDecimalPlaces, + ) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { token?.let { t -> val depositVisible = !readOnly && onDeposit != null && (balance?.toBigDecimalOrNull()?.compareTo(BigDecimal.ZERO) ?: 0) == 0 diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/PriceInputArea.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/PriceInputArea.kt index ee0a026909..55d48ee4c3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/PriceInputArea.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/PriceInputArea.kt @@ -33,6 +33,7 @@ import one.mixin.android.api.response.web3.QuoteResult import one.mixin.android.api.response.web3.SwapToken import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.ui.home.web3.trade.SwapViewModel +import one.mixin.android.ui.home.web3.trade.TRADE_INPUT_MAX_DECIMAL_PLACES import java.math.BigDecimal import java.math.RoundingMode @@ -175,6 +176,7 @@ fun PriceInputArea( title = stringResource(id = R.string.limit_price, priceDisplayState.displayChainName), readOnly = false, selectClick = null, + maxDecimalPlaces = TRADE_INPUT_MAX_DECIMAL_PLACES, onInputChanged = { userInput -> displayPrice = userInput val inputPrice = userInput.toBigDecimalOrNull() diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt index 03c17b26f2..33be2984c0 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt @@ -55,6 +55,50 @@ import one.mixin.android.ui.home.inscription.component.AutoSizeText import one.mixin.android.widget.CoilRoundedHexagonTransformation import java.math.BigDecimal +internal const val TRADE_INPUT_MAX_DECIMAL_PLACES = 8 + +internal fun tradeInputMaxDecimalPlaces( + isCommonWallet: Boolean, + precision: Int, +): Int { + return if (isCommonWallet && precision >= 0) { + precision + } else { + TRADE_INPUT_MAX_DECIMAL_PLACES + } +} + +internal fun SwapToken?.tradeInputMaxDecimalPlaces(): Int { + return this?.let { token -> + tradeInputMaxDecimalPlaces(token.walletId != null, token.decimals) + } ?: TRADE_INPUT_MAX_DECIMAL_PLACES +} + +internal fun isTradeInputDecimalAllowed( + value: String, + maxDecimalPlaces: Int? = TRADE_INPUT_MAX_DECIMAL_PLACES, +): Boolean { + maxDecimalPlaces ?: return true + if (maxDecimalPlaces < 0) return true + val decimalIndex = value.indexOf('.') + if (decimalIndex < 0) return true + if (maxDecimalPlaces == 0) return false + return value.length - decimalIndex - 1 <= maxDecimalPlaces +} + +internal fun limitTradeInputDecimalPlaces( + value: String, + maxDecimalPlaces: Int? = TRADE_INPUT_MAX_DECIMAL_PLACES, +): String { + maxDecimalPlaces ?: return value + if (maxDecimalPlaces < 0) return value + val decimalIndex = value.indexOf('.') + if (decimalIndex < 0) return value + if (maxDecimalPlaces == 0) return value.substring(0, decimalIndex) + val endIndex = (decimalIndex + 1 + maxDecimalPlaces).coerceAtMost(value.length) + return value.substring(0, endIndex) +} + @SuppressLint("UnrememberedMutableState") @Composable fun InputContent( @@ -68,6 +112,7 @@ fun InputContent( inputFontSize: TextUnit = 24.sp, inputFontWeight: FontWeight = FontWeight.Black, autoFocus: Boolean = false, + maxDecimalPlaces: Int? = null, ) { if (readOnly) { Column(modifier = Modifier.fillMaxWidth()) { @@ -105,21 +150,24 @@ fun InputContent( } val interactionSource = remember { MutableInteractionSource() } var textFieldValue by remember { + val limitedText = limitTradeInputDecimalPlaces(text, maxDecimalPlaces) mutableStateOf( TextFieldValue( - text = text, - selection = TextRange(text.length) + text = limitedText, + selection = TextRange(limitedText.length) ) ) } - LaunchedEffect(text) { - if (textFieldValue.text != text) { + LaunchedEffect(text, maxDecimalPlaces) { + val limitedText = limitTradeInputDecimalPlaces(text, maxDecimalPlaces) + if (textFieldValue.text != limitedText) { textFieldValue = TextFieldValue( - text = text, - selection = TextRange(text.length) + text = limitedText, + selection = TextRange(limitedText.length) ) } + if (text != limitedText) onInputChanged?.invoke(limitedText) } Column(modifier = Modifier.fillMaxWidth()) { @@ -136,8 +184,11 @@ fun InputContent( BasicTextField( value = textFieldValue, onValueChange = { + if (!isTradeInputDecimalAllowed(it.text, maxDecimalPlaces)) { + return@BasicTextField + } textFieldValue = it - val v = try { + try { if (it.text.isBlank()) BigDecimal.ZERO else BigDecimal(it.text) } catch (e: Exception) { return@BasicTextField diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt index cc28745cc0..db7cc77c20 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt @@ -153,14 +153,6 @@ fun LimitOrderContent( val viewModel = hiltViewModel() - var inputText by remember { mutableStateOf(initialAmount ?: "") } - var outputText by remember { mutableStateOf("") } - - LaunchedEffect(lastOrderTime) { - inputText = initialAmount ?: "" - outputText = "" - } - var limitPriceText by remember { mutableStateOf("") } var marketPriceClickTime by remember { mutableStateOf(lastOrderTime) } var priceMultiplier by remember { mutableStateOf(null) } @@ -177,6 +169,18 @@ fun LimitOrderContent( var toToken by remember(from, to, isReverse) { mutableStateOf(if (isReverse) from else to) } + val fromMaxDecimalPlaces = fromToken.tradeInputMaxDecimalPlaces() + val toMaxDecimalPlaces = toToken.tradeInputMaxDecimalPlaces() + + var inputText by remember { + mutableStateOf(limitTradeInputDecimalPlaces(initialAmount ?: "", fromMaxDecimalPlaces)) + } + var outputText by remember { mutableStateOf("") } + + LaunchedEffect(lastOrderTime) { + inputText = limitTradeInputDecimalPlaces(initialAmount ?: "", fromMaxDecimalPlaces) + outputText = "" + } var isButtonEnabled by remember { mutableStateOf(true) } var isSubmitting by remember { mutableStateOf(false) } @@ -217,7 +221,7 @@ fun LimitOrderContent( if (fromAmount != null && standardPrice != null && fromAmount > BigDecimal.ZERO && standardPrice > BigDecimal.ZERO) { val toAmount = fromAmount.multiply(standardPrice).setScale(8, RoundingMode.DOWN) - outputText = toAmount.stripTrailingZeros().toPlainString() + outputText = limitTradeInputDecimalPlaces(toAmount.stripTrailingZeros().toPlainString(), toMaxDecimalPlaces) } else { outputText = "" } @@ -273,7 +277,8 @@ fun LimitOrderContent( .clickable { AnalyticsTracker.trackSpotSwitchSendReceive() isReverse = !isReverse - inputText = outputText + val nextFromMaxDecimalPlaces = toToken.tradeInputMaxDecimalPlaces() + inputText = limitTradeInputDecimalPlaces(outputText, nextFromMaxDecimalPlaces) val oldPrice = limitPriceText.toBigDecimalOrNull() if (oldPrice != null && oldPrice > BigDecimal.ZERO) { @@ -322,20 +327,20 @@ fun LimitOrderContent( val standardPrice = limitPriceText.toBigDecimalOrNull() if (fromAmount != null && standardPrice != null && fromAmount > BigDecimal.ZERO && standardPrice > BigDecimal.ZERO) { val calculatedOutput = fromAmount.multiply(standardPrice).setScale(8, RoundingMode.DOWN) - outputText = calculatedOutput.stripTrailingZeros().toPlainString() + outputText = limitTradeInputDecimalPlaces(calculatedOutput.stripTrailingZeros().toPlainString(), toMaxDecimalPlaces) } else if (fromAmount == null || fromAmount == BigDecimal.ZERO) { outputText = "" } } - }, onDeposit = onDeposit, displayBalanceOverride = if (it.isNativeSolAsset()) fromBalance else null, onMax = { + }, onDeposit = onDeposit, displayBalanceOverride = if (it.isNativeSolAsset()) fromBalance else null, maxDecimalPlaces = fromMaxDecimalPlaces, onMax = { AnalyticsTracker.trackSpotSendInputBalance() - inputText = formatBalanceInput(availableFromBalance, fromToken?.isWeb3 == true) + inputText = limitTradeInputDecimalPlaces(formatBalanceInput(availableFromBalance, fromToken?.isWeb3 == true), fromMaxDecimalPlaces) if (inputText.isNotBlank()) { val fromAmount = inputText.toBigDecimalOrNull() val standardPrice = limitPriceText.toBigDecimalOrNull() if (fromAmount != null && standardPrice != null && fromAmount > BigDecimal.ZERO && standardPrice > BigDecimal.ZERO) { val calculatedOutput = fromAmount.multiply(standardPrice).setScale(8, RoundingMode.DOWN) - outputText = calculatedOutput.stripTrailingZeros().toPlainString() + outputText = limitTradeInputDecimalPlaces(calculatedOutput.stripTrailingZeros().toPlainString(), toMaxDecimalPlaces) } else if (fromAmount == null || fromAmount == BigDecimal.ZERO) { outputText = "" } @@ -366,21 +371,22 @@ fun LimitOrderContent( val standardPrice = limitPriceText.toBigDecimalOrNull() if (toAmount != null && standardPrice != null && toAmount > BigDecimal.ZERO && standardPrice > BigDecimal.ZERO) { val calculatedInput = toAmount.divide(standardPrice, 8, RoundingMode.DOWN) - inputText = calculatedInput.stripTrailingZeros().toPlainString() + inputText = limitTradeInputDecimalPlaces(calculatedInput.stripTrailingZeros().toPlainString(), fromMaxDecimalPlaces) } else if (toAmount == null || toAmount == BigDecimal.ZERO) { inputText = "" } } }, onDeposit = null, + maxDecimalPlaces = toMaxDecimalPlaces, onMax = { - outputText = formatBalanceInput(toBalance, toToken?.isWeb3 == true) + outputText = limitTradeInputDecimalPlaces(formatBalanceInput(toBalance, toToken?.isWeb3 == true), toMaxDecimalPlaces) if (outputText.isNotBlank()) { val toAmount = outputText.toBigDecimalOrNull() val standardPrice = limitPriceText.toBigDecimalOrNull() if (toAmount != null && standardPrice != null && toAmount > BigDecimal.ZERO && standardPrice > BigDecimal.ZERO) { val calculatedInput = toAmount.divide(standardPrice, 8, RoundingMode.DOWN) - inputText = calculatedInput.stripTrailingZeros().toPlainString() + inputText = limitTradeInputDecimalPlaces(calculatedInput.stripTrailingZeros().toPlainString(), fromMaxDecimalPlaces) } else if (toAmount == null || toAmount == BigDecimal.ZERO) { inputText = "" } @@ -632,12 +638,13 @@ fun LimitOrderContent( AnalyticsTracker.trackSpotPriceInputPercent(label) }, onSetInput = { - inputText = it - val fromAmount = it.toBigDecimalOrNull() + val limitedInput = limitTradeInputDecimalPlaces(it, fromMaxDecimalPlaces) + inputText = limitedInput + val fromAmount = limitedInput.toBigDecimalOrNull() val standardPrice = limitPriceText.toBigDecimalOrNull() if (fromAmount != null && standardPrice != null && fromAmount > BigDecimal.ZERO && standardPrice > BigDecimal.ZERO) { val calculatedOutput = fromAmount.multiply(standardPrice).setScale(8, RoundingMode.DOWN) - outputText = calculatedOutput.stripTrailingZeros().toPlainString() + outputText = limitTradeInputDecimalPlaces(calculatedOutput.stripTrailingZeros().toPlainString(), toMaxDecimalPlaces) } else if (fromAmount == null || fromAmount == BigDecimal.ZERO) { outputText = "" } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapContent.kt index c7805c4652..afb40a2b95 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapContent.kt @@ -113,11 +113,6 @@ fun SwapContent( var quoteMin by remember { mutableStateOf(null) } var quoteMax by remember { mutableStateOf(null) } - var inputText by remember { mutableStateOf(initialAmount ?: "") } - LaunchedEffect(lastOrderTime) { - inputText = initialAmount ?: "" - } - var isLoading by remember { mutableStateOf(false) } var isReverse by remember { mutableStateOf(false) } var invalidFlag by remember { mutableStateOf(false) } @@ -128,6 +123,14 @@ fun SwapContent( var toToken by remember(from, to, isReverse) { mutableStateOf(if (isReverse) from else to) } + val fromMaxDecimalPlaces = fromToken.tradeInputMaxDecimalPlaces() + + var inputText by remember { + mutableStateOf(limitTradeInputDecimalPlaces(initialAmount ?: "", fromMaxDecimalPlaces)) + } + LaunchedEffect(lastOrderTime) { + inputText = limitTradeInputDecimalPlaces(initialAmount ?: "", fromMaxDecimalPlaces) + } val shouldRefreshQuote = remember { MutableStateFlow(inputText) } var isButtonEnabled by remember { mutableStateOf(true) } @@ -242,7 +245,8 @@ fun SwapContent( } } quoteResult?.let { - inputText = it.outAmount + val nextFromMaxDecimalPlaces = toToken.tradeInputMaxDecimalPlaces() + inputText = limitTradeInputDecimalPlaces(it.outAmount, nextFromMaxDecimalPlaces) quoteResult = null } context.clickVibrate() @@ -268,11 +272,12 @@ fun SwapContent( onInputChanged = { inputText = it }, onDeposit = onDeposit, displayBalanceOverride = if (from.isNativeSolAsset()) fromBalance else null, + maxDecimalPlaces = fromMaxDecimalPlaces, onMax = { AnalyticsTracker.trackSpotSendInputBalance() val balance = availableFromBalanceValue if (balance > BigDecimal.ZERO) { - inputText = balance.stripTrailingZeros().toPlainString() + inputText = limitTradeInputDecimalPlaces(balance.stripTrailingZeros().toPlainString(), fromMaxDecimalPlaces) } else { inputText = "" } @@ -301,7 +306,9 @@ fun SwapContent( inputText = inputText, quoteMin = quoteMin, quoteMax = quoteMax, - onInputTextChange = { inputText = it }, + onInputTextChange = { + inputText = limitTradeInputDecimalPlaces(it, fromMaxDecimalPlaces) + }, onInvalidFlagChange = { invalidFlag = !invalidFlag }, onSwitchToLimitOrder = onSwitchToLimitOrder, ) @@ -341,7 +348,7 @@ fun SwapContent( InputAction("25%", showBorder = true) { AnalyticsTracker.trackSpotSendInputPercent("25%") if (balance > BigDecimal.ZERO) { - inputText = (balance * BigDecimal("0.25")).stripTrailingZeros().toPlainString() + inputText = limitTradeInputDecimalPlaces((balance * BigDecimal("0.25")).stripTrailingZeros().toPlainString(), fromMaxDecimalPlaces) } else { inputText = "" } @@ -349,7 +356,7 @@ fun SwapContent( InputAction("50%", showBorder = true) { AnalyticsTracker.trackSpotSendInputPercent("50%") if (balance > BigDecimal.ZERO) { - inputText = (balance * BigDecimal("0.5")).stripTrailingZeros().toPlainString() + inputText = limitTradeInputDecimalPlaces((balance * BigDecimal("0.5")).stripTrailingZeros().toPlainString(), fromMaxDecimalPlaces) } else { inputText = "" } @@ -357,7 +364,7 @@ fun SwapContent( InputAction(stringResource(R.string.Max), showBorder = true) { AnalyticsTracker.trackSpotSendInputPercent("max") if (balance > BigDecimal.ZERO) { - inputText = balance.stripTrailingZeros().toPlainString() + inputText = limitTradeInputDecimalPlaces(balance.stripTrailingZeros().toPlainString(), fromMaxDecimalPlaces) } else { inputText = "" } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt index f509363f6e..c57889184e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt @@ -72,6 +72,7 @@ import one.mixin.android.ui.home.web3.trade.InputContent import one.mixin.android.ui.home.web3.trade.KeyboardAwareBox import one.mixin.android.ui.home.web3.trade.SwapActivity import one.mixin.android.ui.home.web3.trade.TradeFragment +import one.mixin.android.ui.home.web3.trade.limitTradeInputDecimalPlaces import one.mixin.android.ui.wallet.AddFeeBottomSheetDialogFragment import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.ui.wallet.alert.components.cardBackground @@ -144,6 +145,7 @@ fun OpenPositionPage( var currentToken by remember { mutableStateOf(selectedToken) } var availableTokens by remember { mutableStateOf>(emptyList()) } var usdtAmount by remember { mutableStateOf("") } + val amountMaxDecimalPlaces = PERPS_AMOUNT_MAX_DECIMAL_PLACES var takeProfitPrice by remember { mutableStateOf("") } var stopLossPrice by remember { mutableStateOf("") } var remoteLiquidationPrice by remember { mutableStateOf(null) } @@ -160,6 +162,10 @@ fun OpenPositionPage( } var leverage by remember(marketId) { mutableFloatStateOf(savedLeverage.toFloat()) } + LaunchedEffect(currentToken?.assetId, amountMaxDecimalPlaces) { + usdtAmount = limitTradeInputDecimalPlaces(usdtAmount, amountMaxDecimalPlaces) + } + LaunchedEffect(marketId) { while (true) { viewModel.loadMarketDetail( @@ -413,6 +419,7 @@ fun OpenPositionPage( }, onInputChanged = { usdtAmount = it }, tokenIconSize = 25.dp, + maxDecimalPlaces = amountMaxDecimalPlaces, ) Row( @@ -435,7 +442,7 @@ fun OpenPositionPage( ), modifier = Modifier.clickable { AnalyticsTracker.trackPerpsAmountInputBalance() - usdtAmount = currentToken?.balance ?: "0" + usdtAmount = limitTradeInputDecimalPlaces(currentToken?.balance ?: "0", amountMaxDecimalPlaces) } ) if (showAddAction) { @@ -812,10 +819,13 @@ fun OpenPositionPage( floating = { fun applyBalancePercent(percent: BigDecimal) { if (tokenBalance > BigDecimal.ZERO) { - usdtAmount = tokenBalance - .multiply(percent) - .stripTrailingZeros() - .toPlainString() + usdtAmount = limitTradeInputDecimalPlaces( + tokenBalance + .multiply(percent) + .stripTrailingZeros() + .toPlainString(), + amountMaxDecimalPlaces, + ) } else { usdtAmount = "" } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt index 3145719aec..d57bea71ac 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAddBottomSheetDialogFragment.kt @@ -83,6 +83,7 @@ import one.mixin.android.ui.home.web3.trade.InputContent import one.mixin.android.ui.home.web3.trade.KeyboardAwareBox import one.mixin.android.ui.home.web3.trade.SwapActivity import one.mixin.android.ui.home.web3.trade.TradeFragment +import one.mixin.android.ui.home.web3.trade.limitTradeInputDecimalPlaces import one.mixin.android.ui.home.web3.trade.perps.formatPerpsPrice import one.mixin.android.ui.home.web3.trade.perps.formatPerpsQuantity import one.mixin.android.ui.home.web3.trade.perps.formatPerpsUsdDecimal @@ -267,6 +268,7 @@ private fun PerpsAddContent( val focusManager = LocalFocusManager.current val viewModel = hiltViewModel() var amount by remember(position.positionId) { mutableStateOf("") } + val amountMaxDecimalPlaces = PERPS_AMOUNT_MAX_DECIMAL_PLACES var remoteLiquidationPrice by remember(position.positionId) { mutableStateOf(null) } var isLiquidationLoading by remember(position.positionId) { mutableStateOf(false) } var liquidationJob by remember(position.positionId) { mutableStateOf(null) } @@ -288,6 +290,10 @@ private fun PerpsAddContent( .ifBlank { position.entryPrice } val priceScale = market?.priceScale ?: position.priceScale + LaunchedEffect(selectedToken?.assetId, amountMaxDecimalPlaces) { + amount = limitTradeInputDecimalPlaces(amount, amountMaxDecimalPlaces) + } + LaunchedEffect(amount, belowMinimumMargin, aboveMaximumMargin) { val addMargin = amount.toBigDecimalOrNull() if (addMargin == null || addMargin <= BigDecimal.ZERO || belowMinimumMargin || aboveMaximumMargin) { @@ -472,6 +478,7 @@ private fun PerpsAddContent( onInputChanged = { amount = it }, tokenIconSize = 25.dp, autoFocus = true, + maxDecimalPlaces = amountMaxDecimalPlaces, ) Row( @@ -493,7 +500,7 @@ private fun PerpsAddContent( textAlign = TextAlign.Start, ), modifier = Modifier.clickable { - amount = selectedToken?.balance ?: "0" + amount = limitTradeInputDecimalPlaces(selectedToken?.balance ?: "0", amountMaxDecimalPlaces) }, ) if (insufficientBalance || tokenBalance <= BigDecimal.ZERO) { @@ -618,7 +625,10 @@ private fun PerpsAddContent( floating = { fun applyBalancePercent(percent: BigDecimal) { amount = if (tokenBalance > BigDecimal.ZERO) { - tokenBalance.multiply(percent).stripTrailingZeros().toPlainString() + limitTradeInputDecimalPlaces( + tokenBalance.multiply(percent).stripTrailingZeros().toPlainString(), + amountMaxDecimalPlaces, + ) } else { "" } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsFormat.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsFormat.kt index 68ecfb42b6..d7312bcbfb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsFormat.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsFormat.kt @@ -9,6 +9,7 @@ import java.math.BigDecimal import java.math.RoundingMode const val PERPS_USD_SYMBOL = "\$" +internal const val PERPS_AMOUNT_MAX_DECIMAL_PLACES = 2 fun PerpsMarket.changePercent(): BigDecimal { return try { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt index 2cbc184dfa..58fed75f17 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt @@ -307,15 +307,18 @@ private fun PerpsTpSlContent( var inputType by rememberSaveable(initialPrice, mode.name) { mutableStateOf(defaultInputType) } + val initialPriceInput = remember(initialPrice, safePriceScale) { + normalizePriceInput(initialPrice, safePriceScale) + } var priceFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(textFieldValueAtEnd(initialPrice)) + mutableStateOf(textFieldValueAtEnd(initialPriceInput)) } var percentFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf( textFieldValueAtEnd( normalizePercentInput( derivePercentMagnitudeInput( - priceInput = initialPrice, + priceInput = initialPriceInput, percentBasePrice = percentBasePrice, leverage = leverageValue, isLong = isLong, From 86f98bd1665e3a96c08e274c8cce821959fdf726 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 9 Jun 2026 15:01:51 +0800 Subject: [PATCH 2/3] test(trade): cover decimal input limits --- .../ui/home/web3/trade/TradeInputTest.kt | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/trade/TradeInputTest.kt diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradeInputTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradeInputTest.kt new file mode 100644 index 0000000000..be737ba7d1 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradeInputTest.kt @@ -0,0 +1,47 @@ +package one.mixin.android.ui.home.web3.trade + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TradeInputTest { + @Test + fun tradeAmountInputUsesWalletSpecificDecimalPlaces() { + assertEquals(8, tradeInputMaxDecimalPlaces(isCommonWallet = false, precision = 18)) + assertEquals(6, tradeInputMaxDecimalPlaces(isCommonWallet = true, precision = 6)) + assertEquals(0, tradeInputMaxDecimalPlaces(isCommonWallet = true, precision = 0)) + assertEquals(8, tradeInputMaxDecimalPlaces(isCommonWallet = true, precision = -1)) + } + + @Test + fun tradeAmountInputAllowsConfiguredDecimalPlaces() { + assertTrue(isTradeInputDecimalAllowed("")) + assertTrue(isTradeInputDecimalAllowed("12")) + assertTrue(isTradeInputDecimalAllowed("12.")) + assertTrue(isTradeInputDecimalAllowed("12.12345678")) + assertTrue(isTradeInputDecimalAllowed("0.00000000")) + + assertFalse(isTradeInputDecimalAllowed("12.123456789")) + assertFalse(isTradeInputDecimalAllowed("0.000000001")) + assertFalse(isTradeInputDecimalAllowed("1.234", maxDecimalPlaces = 2)) + assertFalse(isTradeInputDecimalAllowed("5.", maxDecimalPlaces = 0)) + assertFalse(isTradeInputDecimalAllowed("5.0", maxDecimalPlaces = 0)) + + assertTrue(isTradeInputDecimalAllowed("12.123456789", maxDecimalPlaces = null)) + } + + @Test + fun tradeAmountInputLimitsProgrammaticValuesToConfiguredDecimalPlaces() { + assertEquals("", limitTradeInputDecimalPlaces("")) + assertEquals("12", limitTradeInputDecimalPlaces("12")) + assertEquals("12.", limitTradeInputDecimalPlaces("12.")) + assertEquals("12.12345678", limitTradeInputDecimalPlaces("12.12345678")) + assertEquals("12.12345678", limitTradeInputDecimalPlaces("12.123456789")) + assertEquals("0.00000000", limitTradeInputDecimalPlaces("0.000000001")) + assertEquals("1.23", limitTradeInputDecimalPlaces("1.234", maxDecimalPlaces = 2)) + assertEquals("5", limitTradeInputDecimalPlaces("5.", maxDecimalPlaces = 0)) + assertEquals("5", limitTradeInputDecimalPlaces("5.123", maxDecimalPlaces = 0)) + assertEquals("12.123456789", limitTradeInputDecimalPlaces("12.123456789", maxDecimalPlaces = null)) + } +} From 6ba941cb6490b443bbc791187a9d557e020d0d25 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 17 Jun 2026 10:24:32 +0800 Subject: [PATCH 3/3] fix(trade): address decimal input review comments --- .../ui/home/web3/trade/InputTextField.kt | 27 ++++++--- .../SwapTokenListBottomSheetDialogFragment.kt | 2 +- .../ui/home/web3/trade/TradeInputTest.kt | 56 +++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt index 9b8b515d46..8b91b88774 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt @@ -72,7 +72,7 @@ internal fun tradeInputMaxDecimalPlaces( internal fun SwapToken?.tradeInputMaxDecimalPlaces(): Int { return this?.let { token -> - tradeInputMaxDecimalPlaces(token.walletId != null, token.decimals) + tradeInputMaxDecimalPlaces(token.walletId != null && !token.isWeb3, token.decimals) } ?: TRADE_INPUT_MAX_DECIMAL_PLACES } @@ -101,6 +101,21 @@ internal fun limitTradeInputDecimalPlaces( return value.substring(0, endIndex) } +internal fun limitTradeInputTextFieldValue( + value: TextFieldValue, + maxDecimalPlaces: Int? = TRADE_INPUT_MAX_DECIMAL_PLACES, +): TextFieldValue { + val limitedText = limitTradeInputDecimalPlaces(value.text, maxDecimalPlaces) + if (limitedText == value.text) return value + return value.copy( + text = limitedText, + selection = TextRange( + value.selection.start.coerceAtMost(limitedText.length), + value.selection.end.coerceAtMost(limitedText.length), + ), + ) +} + @SuppressLint("UnrememberedMutableState") @Composable fun InputContent( @@ -186,16 +201,14 @@ fun InputContent( BasicTextField( value = textFieldValue, onValueChange = { - if (!isTradeInputDecimalAllowed(it.text, maxDecimalPlaces)) { - return@BasicTextField - } - textFieldValue = it + val limitedValue = limitTradeInputTextFieldValue(it, maxDecimalPlaces) + textFieldValue = limitedValue try { - if (it.text.isBlank()) BigDecimal.ZERO else BigDecimal(it.text) + if (limitedValue.text.isBlank()) BigDecimal.ZERO else BigDecimal(limitedValue.text) } catch (e: Exception) { return@BasicTextField } - onInputChanged?.invoke(it.text) + onInputChanged?.invoke(limitedValue.text) }, maxLines = 1, modifier = Modifier diff --git a/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt index 6ab02c4e1d..ffca3fca8e 100644 --- a/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/swap/SwapTokenListBottomSheetDialogFragment.kt @@ -407,7 +407,7 @@ class SwapTokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() successBlock = { resp -> return@handleMixinResponse resp.data?.map { if (!inMixin) { - it.copy(walletId = Web3Signer.currentWalletId) + it.copy(walletId = Web3Signer.currentWalletId, isWeb3 = true) } else { it } diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradeInputTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradeInputTest.kt index 0525254685..966bf4ad63 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradeInputTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradeInputTest.kt @@ -1,5 +1,9 @@ package one.mixin.android.ui.home.web3.trade +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import one.mixin.android.api.response.web3.SwapChain +import one.mixin.android.api.response.web3.SwapToken import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -14,6 +18,28 @@ class TradeInputTest { assertEquals(8, tradeInputMaxDecimalPlaces(isCommonWallet = true, precision = -1)) } + @Test + fun web3TradeAmountInputUsesSharedDecimalLimitEvenWithWalletId() { + val token = token( + walletId = "web3-wallet", + decimals = 18, + isWeb3 = true, + ) + + assertEquals(8, token.tradeInputMaxDecimalPlaces()) + } + + @Test + fun commonWalletTradeAmountInputUsesTokenDecimalPlaces() { + val token = token( + walletId = "common-wallet", + decimals = 6, + isWeb3 = false, + ) + + assertEquals(6, token.tradeInputMaxDecimalPlaces()) + } + @Test fun tradePriceInputAllowsAtMostEightDecimalPlaces() { assertEquals(8, tradePriceInputMaxDecimalPlaces()) @@ -54,4 +80,34 @@ class TradeInputTest { limitTradeInputDecimalPlaces("12.123456789", maxDecimalPlaces = null) ) } + + @Test + fun tradeInputTextFieldValueLimitsPastedDecimals() { + val value = limitTradeInputTextFieldValue( + value = TextFieldValue( + text = "12.123456789", + selection = TextRange(12), + ), + maxDecimalPlaces = 8, + ) + + assertEquals("12.12345678", value.text) + assertEquals(TextRange(11), value.selection) + } + + private fun token( + walletId: String?, + decimals: Int, + isWeb3: Boolean, + ) = SwapToken( + walletId = walletId, + address = "", + assetId = "asset-id", + decimals = decimals, + name = "Token", + symbol = "TKN", + icon = "", + chain = SwapChain("", "", "", ""), + isWeb3 = isWeb3, + ) }