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 979483e072..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 } @@ -81,8 +81,11 @@ internal fun isTradeInputDecimalAllowed( maxDecimalPlaces: Int? = TRADE_INPUT_MAX_DECIMAL_PLACES, ): Boolean { maxDecimalPlaces ?: return true + if (maxDecimalPlaces < 0) return true val decimalIndex = value.indexOf('.') - return decimalIndex < 0 || value.length - decimalIndex - 1 <= maxDecimalPlaces + if (decimalIndex < 0) return true + if (maxDecimalPlaces == 0) return false + return value.length - decimalIndex - 1 <= maxDecimalPlaces } internal fun limitTradeInputDecimalPlaces( @@ -90,12 +93,29 @@ internal fun limitTradeInputDecimalPlaces( 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) } +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( @@ -147,21 +167,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()) { @@ -178,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/ui/home/web3/trade/perps/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt index 3c7f358c42..00112cf064 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) { @@ -829,10 +836,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, 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 8a29dd5092..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()) @@ -22,7 +48,7 @@ class TradeInputTest { } @Test - fun tradeAmountInputAllowsAtMostEightDecimalPlaces() { + fun tradeAmountInputAllowsConfiguredDecimalPlaces() { assertTrue(isTradeInputDecimalAllowed("")) assertTrue(isTradeInputDecimalAllowed("12")) assertTrue(isTradeInputDecimalAllowed("12.")) @@ -31,21 +57,57 @@ class TradeInputTest { 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 tradeAmountInputLimitsProgrammaticValuesToEightDecimalPlaces() { + 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) ) } + + @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, + ) }