From c451badd89c10fdfcab3b6c11c0d4c59ddad6360 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 14 Sep 2026 15:23:41 +0800 Subject: [PATCH 01/29] feat(perps): support margin adjustments --- .../api/request/perps/OpenOrderRequest.kt | 11 + .../android/api/response/perps/PerpsOrder.kt | 2 + .../mixin/android/api/service/RouteService.kt | 7 + .../mixin/android/db/perps/PerpsOrderDao.kt | 8 +- .../ui/home/web3/trade/InputTextField.kt | 2 +- .../home/web3/trade/perps/OpenedOrderItem.kt | 7 +- .../web3/trade/perps/PerpetualViewModel.kt | 7 + .../PerpsAddBottomSheetDialogFragment.kt | 84 +-- .../PerpsAdjustBottomSheetDialogFragment.kt | 167 +++++ .../PerpsCloseBottomSheetDialogFragment.kt | 88 ++- .../PerpsConfirmBottomSheetDialogFragment.kt | 176 ++--- .../web3/trade/perps/PerpsMarginAmount.kt | 40 ++ .../PerpsMarginBottomSheetDialogFragment.kt | 653 ++++++++++++++++++ .../web3/trade/perps/PerpsMarketDetailPage.kt | 119 ++-- .../web3/trade/perps/PositionDetailPage.kt | 23 +- app/src/main/res/drawable/ic_perps_margin.xml | 13 + .../main/res/drawable/ic_perps_position.xml | 13 + app/src/main/res/values-es/strings.xml | 24 + app/src/main/res/values-in/strings.xml | 24 + app/src/main/res/values-ja/strings.xml | 24 + app/src/main/res/values-ko/strings.xml | 24 + app/src/main/res/values-ms/strings.xml | 24 + app/src/main/res/values-ru/strings.xml | 24 + app/src/main/res/values-zh-rCN/strings.xml | 24 + app/src/main/res/values-zh-rTW/strings.xml | 24 + app/src/main/res/values/strings.xml | 24 + .../api/request/perps/OpenOrderRequestTest.kt | 19 + .../android/db/perps/PerpsOrderDaoTest.kt | 76 ++ .../web3/trade/perps/PerpsMarginAmountTest.kt | 87 +++ 29 files changed, 1597 insertions(+), 221 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt create mode 100644 app/src/main/res/drawable/ic_perps_margin.xml create mode 100644 app/src/main/res/drawable/ic_perps_position.xml create mode 100644 app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt diff --git a/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt b/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt index c71e5f343f..e036fec1a5 100644 --- a/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt +++ b/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt @@ -71,3 +71,14 @@ data class IncreaseOrderRequest( @SerializedName("stop_loss_price") val stopLossPrice: String? = null, ) + +data class AdjustMarginRequest( + @SerializedName("type") + val type: String, + @SerializedName("amount") + val amount: String, + @SerializedName("asset_id") + val assetId: String? = null, + @SerializedName("destination") + val destination: String? = null, +) diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsOrder.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsOrder.kt index 7b8e0c1b3b..e54d36fe6d 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsOrder.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsOrder.kt @@ -69,6 +69,8 @@ data class PerpsOrder( companion object { const val TYPE_OPEN = "open" const val TYPE_INCREASE = "increase_position" + const val TYPE_INCREASE_MARGIN = "increase_margin" + const val TYPE_DECREASE_MARGIN = "decrease_margin" const val TYPE_CLOSE = "close" const val STATUS_PROCESSING = "processing" diff --git a/app/src/main/java/one/mixin/android/api/service/RouteService.kt b/app/src/main/java/one/mixin/android/api/service/RouteService.kt index 95856aeed3..7632c41fc7 100644 --- a/app/src/main/java/one/mixin/android/api/service/RouteService.kt +++ b/app/src/main/java/one/mixin/android/api/service/RouteService.kt @@ -10,6 +10,7 @@ import one.mixin.android.api.request.RouteInstrumentRequest import one.mixin.android.api.request.RoutePriceRequest import one.mixin.android.api.request.RouteTickerRequest import one.mixin.android.api.request.RouteTokenRequest +import one.mixin.android.api.request.perps.AdjustMarginRequest import one.mixin.android.api.request.perps.CloseOrderRequest import one.mixin.android.api.request.perps.CloseOrderResponse import one.mixin.android.api.request.perps.IncreaseOrderRequest @@ -462,6 +463,12 @@ interface RouteService { @Body request: IncreaseOrderRequest, ): MixinResponse + @POST("perps/positions/{id}/margin") + suspend fun adjustPerpsMargin( + @Path("id") positionId: String, + @Body request: AdjustMarginRequest, + ): MixinResponse + @GET("perps/orders") suspend fun getPerpsOrders( @Query("offset") offset: String? = null, diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt index 007c81aad8..7328e38123 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt @@ -19,7 +19,7 @@ interface PerpsOrderDao : BaseDao { SELECT o.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale FROM perps_orders o LEFT JOIN markets m ON m.market_id = o.market_id - WHERE o.order_type IN ('open', 'increase_position', 'close') + WHERE o.order_type IN ('open', 'increase_position', 'increase_margin', 'decrease_margin', 'close') AND o.status != 'processing' AND (:offset IS NULL OR o.created_at < :offset) ORDER BY o.created_at DESC @@ -31,7 +31,7 @@ interface PerpsOrderDao : BaseDao { SELECT o.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale FROM perps_orders o LEFT JOIN markets m ON m.market_id = o.market_id - WHERE o.order_type IN ('open', 'increase_position', 'close') + WHERE o.order_type IN ('open', 'increase_position', 'increase_margin', 'decrease_margin', 'close') AND o.status != 'processing' ORDER BY o.created_at DESC LIMIT :limit @@ -42,7 +42,7 @@ interface PerpsOrderDao : BaseDao { SELECT o.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale FROM perps_orders o LEFT JOIN markets m ON m.market_id = o.market_id - WHERE o.order_type IN ('open', 'increase_position', 'close') + WHERE o.order_type IN ('open', 'increase_position', 'increase_margin', 'decrease_margin', 'close') AND o.status != 'processing' ORDER BY o.created_at DESC """) @@ -52,7 +52,7 @@ interface PerpsOrderDao : BaseDao { SELECT o.*, m.display_symbol, m.icon_url, m.token_symbol, m.price_scale FROM perps_orders o LEFT JOIN markets m ON m.market_id = o.market_id - WHERE o.order_type IN ('open', 'increase_position', 'close') + WHERE o.order_type IN ('open', 'increase_position', 'increase_margin', 'decrease_margin', 'close') AND o.status != 'processing' AND o.market_id = :marketId ORDER BY o.created_at DESC 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 9e715b32d8..4c0d2a31c6 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 @@ -184,12 +184,12 @@ fun InputContent( if (!isTradeInputDecimalAllowed(it.text, maxDecimalPlaces)) { return@BasicTextField } - textFieldValue = it try { if (it.text.isBlank()) BigDecimal.ZERO else BigDecimal(it.text) } catch (e: Exception) { return@BasicTextField } + textFieldValue = it onInputChanged?.invoke(it.text) }, maxLines = 1, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt index bb90724309..7e03adafe5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt @@ -59,11 +59,14 @@ fun OpenedOrderItem( if (quoteColorPref) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed } val isIncrease = order.orderType == PerpsOrder.TYPE_INCREASE + val isMargin = order.orderType == PerpsOrder.TYPE_INCREASE_MARGIN || order.orderType == PerpsOrder.TYPE_DECREASE_MARGIN val isFailed = order.status == PerpsOrder.STATUS_REJECTED val leverageDimmed = isFailed || order.status == PerpsOrder.STATUS_PROCESSING val leverageColor = if (leverageDimmed) MixinAppTheme.colors.textAssist else sideColor val leverageBackgroundColor = leverageColor.copy(alpha = 0.1f) val title = when { + isMargin -> stringResource(if (order.orderType == PerpsOrder.TYPE_INCREASE_MARGIN) R.string.perps_add_margin else R.string.perps_reduce_margin) + + if (isFailed) " · ${stringResource(R.string.Failed)}" else "" isIncrease && isFailed -> stringResource(if (isLong) R.string.Added_Long_Failed else R.string.Added_Short_Failed) isIncrease -> @@ -75,7 +78,7 @@ fun OpenedOrderItem( } val amountValue = if (!isFailed) { - order.payAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO + order.payAmount.toBigDecimalOrNull() } else { null } @@ -127,7 +130,7 @@ fun OpenedOrderItem( Spacer(modifier = Modifier.height(4.dp)) Text( - text = "$quantity ${order.tokenSymbol ?: ""}", + text = if (isMargin) order.displaySymbol ?: order.tokenSymbol.orEmpty() else "$quantity ${order.tokenSymbol ?: ""}", fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, maxLines = 1, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index c1c7fadf6e..3b00ae0890 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -531,6 +531,13 @@ class PerpetualViewModel @Inject constructor( } } + suspend fun adjustPerpsMargin( + positionId: String, + request: one.mixin.android.api.request.perps.AdjustMarginRequest, + ) = withContext(Dispatchers.IO) { + routeService.adjustPerpsMargin(positionId, request) + } + fun increasePerpsPosition( positionId: String, assetId: String, 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 705f42ea84..a1c6361b71 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 @@ -57,6 +57,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -177,23 +181,30 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( @Composable override fun ComposeContent() { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val viewModel = hiltViewModel() + val livePosition by remember(position.positionId) { + viewModel.observePosition(position.positionId) + }.collectAsStateWithLifecycle(initialValue = position) val acceptedPerpAssetIdsOrdered = remember { readAcceptedPerpAssetIds(context) } val acceptedPerpAssetIds = remember(acceptedPerpAssetIdsOrdered) { acceptedPerpAssetIdsOrdered.toSet() } var selectedToken by remember { mutableStateOf(null) } var market by remember { mutableStateOf(null) } - LaunchedEffect(position.marketId) { + LaunchedEffect(position.marketId, lifecycleOwner) { market = viewModel.getMarketFromDb(position.marketId) - while (true) { - viewModel.loadMarketDetail( - marketId = position.marketId, - onSuccess = { data -> - market = data - }, - onError = {}, - ) - delay(PERPS_ADD_REFRESH_INTERVAL_MS) + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + viewModel.refreshSinglePosition(position.positionId, position.walletId) + viewModel.loadMarketDetail( + marketId = position.marketId, + onSuccess = { data -> + market = data + }, + onError = {}, + ) + delay(PERPS_ADD_REFRESH_INTERVAL_MS) + } } } @@ -221,7 +232,7 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( MixinAppTheme { PerpsAddContent( - position = position, + position = livePosition ?: position, market = market, selectedToken = selectedToken, showLiquidationPrice = showLiquidationPrice, @@ -305,7 +316,7 @@ private fun PerpsAddContent( .ifBlank { position.entryPrice } val priceScale = market?.priceScale ?: position.priceScale - LaunchedEffect(amount, belowMinimumMargin, aboveMaximumMargin) { + LaunchedEffect(amount, belowMinimumMargin, aboveMaximumMargin, position.updatedAt, market?.last) { liquidationPriceLimit = null val addMargin = amount.toBigDecimalOrNull() if (!shouldRequestLiquidationPrice(addMargin, minimumMargin) || aboveMaximumMargin) { @@ -595,28 +606,12 @@ private fun PerpsAddContent( .fillMaxWidth() .padding(horizontal = 16.dp), ) { - PerpsAddInfoRow( - title = stringResource(R.string.add_position_add_size), - value = formatAddSizeValue( - amount = amount, - leverage = position.leverage, - price = currentPrice, - tokenSymbol = position.tokenSymbol.orEmpty(), - ), - onTipClick = { - showPerpsGuide(PerpetualGuideBottomSheetDialogFragment.TAB_POSITION) - }, - ) - Spacer(modifier = Modifier.height(16.dp)) PerpsAddInfoRow( title = stringResource(R.string.add_position_total_size), - value = formatTotalSizeValue( - currentQuantity = position.quantity, - amount = amount, - leverage = position.leverage, - price = currentPrice, - tokenSymbol = position.tokenSymbol.orEmpty(), - ), + value = listOfNotNull( + formatTotalSizeValue(position.quantity, "0", position.leverage, currentPrice, position.tokenSymbol.orEmpty()), + if (hasInputAmount) formatTotalSizeValue(position.quantity, amount, position.leverage, currentPrice, position.tokenSymbol.orEmpty()) else null, + ).joinToString(" → "), onTipClick = { showPerpsGuide(PerpetualGuideBottomSheetDialogFragment.TAB_POSITION) }, @@ -627,7 +622,10 @@ private fun PerpsAddContent( value = when (displayLiquidationPrice) { "-" -> "-" null -> "-" - else -> formatPerpsPrice(displayLiquidationPrice, priceScale) + else -> listOfNotNull( + position.liquidationPrice?.takeIf { hasInputAmount && it.isNotBlank() }?.let { formatPerpsPrice(it, priceScale) }, + formatPerpsPrice(displayLiquidationPrice, priceScale), + ).joinToString(" → ") }, isLoading = isLiquidationLoading && displayLiquidationPrice != "-", onTipClick = { @@ -804,7 +802,7 @@ private fun BottomActions( } @Composable -private fun PerpsAddInfoRow( +internal fun PerpsAddInfoRow( title: String, value: String, valueColor: Color = MixinAppTheme.colors.textAssist, @@ -818,7 +816,7 @@ private fun PerpsAddInfoRow( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable( + modifier = Modifier.weight(1f).clickable( enabled = onTipClick != null, interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -853,6 +851,7 @@ private fun PerpsAddInfoRow( fontSize = 14.sp, color = valueColor, textAlign = TextAlign.End, + modifier = Modifier.weight(1.5f).padding(start = 12.dp), ) } } @@ -895,21 +894,6 @@ private fun calculateAddQuantity( .divide(priceValue, 8, RoundingMode.HALF_UP) } -private fun formatAddSizeValue( - amount: String, - leverage: Int, - price: String, - tokenSymbol: String, -): String { - val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO - val quantityText = formatPerpsQuantity(calculateAddQuantity(amount, leverage, price)) - val usdValue = formatPerpsUsdDecimal(amountValue.multiply(BigDecimal(leverage.coerceAtLeast(0)))) - return listOf(quantityText, tokenSymbol) - .filter { it.isNotBlank() } - .joinToString(" ") - .let { "$it ($usdValue)" } -} - private fun formatTotalSizeValue( currentQuantity: String, amount: String, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..7278dce72f --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt @@ -0,0 +1,167 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.view.View +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.screenHeight +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.ui.wallet.alert.components.cardBackground + +@AndroidEntryPoint +class PerpsAdjustBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + companion object { + const val TAG = "PerpsAdjustBottomSheetDialogFragment" + } + + var onAddMargin: (() -> Unit)? = null + var onReduceMargin: (() -> Unit)? = null + var onAddPosition: (() -> Unit)? = null + + override fun getTheme() = R.style.AppTheme_Dialog + + override fun getBottomSheetHeight(view: View): Int = + requireContext().screenHeight() - view.getSafeAreaInsetsTop() + + @Composable + override fun ComposeContent() { + MixinAppTheme { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MixinAppTheme.colors.background) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 40.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.perps_adjust_title), + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { dismiss() }) { + Icon( + painter = painterResource(R.drawable.ic_circle_close), + contentDescription = stringResource(R.string.close), + tint = Color.Unspecified, + modifier = Modifier.size(26.dp), + ) + } + } + Spacer(Modifier.height(20.dp)) + SectionTitle(R.string.Margin) + AdjustAction(R.string.perps_add_margin, R.string.perps_add_margin_description, R.drawable.ic_perps_margin, true) { + dismiss() + onAddMargin?.invoke() + } + Spacer(Modifier.height(8.dp)) + AdjustAction(R.string.perps_reduce_margin, R.string.perps_reduce_margin_description, R.drawable.ic_perps_margin, false) { + dismiss() + onReduceMargin?.invoke() + } + Spacer(Modifier.height(20.dp)) + SectionTitle(R.string.perps_position) + AdjustAction(R.string.perps_add_to_position, R.string.perps_add_position_description, R.drawable.ic_perps_position, true) { + dismiss() + onAddPosition?.invoke() + } + Spacer(Modifier.height(32.dp)) + Text( + text = stringResource(R.string.perps_margin_or_position), + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + lineHeight = 20.sp, + modifier = Modifier.padding(horizontal = 12.dp), + ) + Spacer(Modifier.height(12.dp)) + listOf(R.string.perps_margin_tip, R.string.perps_position_tip, R.string.perps_low_margin_tip).forEach { tip -> + Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("•", color = MixinAppTheme.colors.textAssist, fontSize = 14.sp) + Text(stringResource(tip), color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, lineHeight = 20.sp) + } + } + } + } + } + + override fun showError(error: String) = Unit +} + +@Composable +private fun SectionTitle(@StringRes title: Int) { + Text( + text = stringResource(title), + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.padding(start = 4.dp, bottom = 10.dp), + ) +} + +@Composable +private fun AdjustAction( + @StringRes title: Int, + @StringRes description: Int, + @DrawableRes icon: Int, + increase: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(modifier = Modifier.size(24.dp)) { + Icon(painterResource(icon), null, tint = MixinAppTheme.colors.textRemarks, modifier = Modifier.size(24.dp)) + Icon( + painterResource(if (increase) R.drawable.ic_perps_add else R.drawable.ic_perps_minus), + null, + tint = Color.Unspecified, + modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(title), color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, lineHeight = 20.sp) + Spacer(Modifier.height(4.dp)) + Text(stringResource(description), color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, lineHeight = 18.sp) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index e500e39676..fb1ea805c5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -83,6 +83,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen companion object { const val TAG = "PerpsCloseBottomSheetDialogFragment" private const val ARGS_POSITION_ID = "args_position_id" + private const val ARGS_REDUCE_MARGIN_AMOUNT = "args_reduce_margin_amount" private const val ARGS_SIDE = "args_side" private const val ARGS_MARGIN = "args_margin" private const val ARGS_LEVERAGE = "args_leverage" @@ -94,9 +95,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen fun newInstance( position: PerpsPosition, + reduceMarginAmount: String? = null, ): PerpsCloseBottomSheetDialogFragment { return PerpsCloseBottomSheetDialogFragment().withArgs { putString(ARGS_POSITION_ID, position.positionId) + putString(ARGS_REDUCE_MARGIN_AMOUNT, reduceMarginAmount) putString(ARGS_SIDE, position.side) putString(ARGS_MARGIN, position.margin) putInt(ARGS_LEVERAGE, position.leverage) @@ -146,6 +149,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private val positionId by lazy { requireNotNull(requireArguments().getString(ARGS_POSITION_ID)) { "positionId is null" } } + private val reduceMarginAmount by lazy { arguments?.getString(ARGS_REDUCE_MARGIN_AMOUNT) } + private val isReduceMargin get() = reduceMarginAmount != null + private val isLong by lazy { requireNotNull(requireArguments().getString(ARGS_SIDE)) { "side is null" } .equals("long", ignoreCase = true) @@ -309,9 +315,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Text( text = stringResource( id = when (step) { - Step.Pending -> R.string.confirm_closing_position - Step.Done -> R.string.Position_Closed - Step.Error -> if (isLong) R.string.Closed_Long_Failed else R.string.Closed_Short_Failed + Step.Pending -> if (isReduceMargin) R.string.perps_confirm_reduce_margin_title else R.string.confirm_closing_position + Step.Done -> if (isReduceMargin) R.string.perps_margin_submitted else R.string.Position_Closed + Step.Error -> if (isReduceMargin) R.string.Failed else if (isLong) R.string.Closed_Long_Failed else R.string.Closed_Short_Failed Step.Sending -> R.string.Sending } ), @@ -363,7 +369,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen fallingColor } - val estimatedReceive = try { + val estimatedReceive = if (isReduceMargin) reduceMarginAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO else try { val margin = BigDecimal(margin) val unrealizedPnl = BigDecimal(latestUnrealizedPnl) margin + unrealizedPnl @@ -413,6 +419,20 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) } Box(modifier = Modifier.height(20.dp)) + if (isReduceMargin) { + Text( + text = stringResource(R.string.Amount).uppercase(), + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatPerpsExactUsdDecimal(estimatedReceive), + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(20.dp)) + } settleAssetItem?.let { asset -> Text( text = stringResource(R.string.Estimated_Receive).uppercase(), @@ -431,7 +451,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", + text = if (isReduceMargin) { + val price = asset.priceUsd.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + price?.let { "${estimatedReceive.divide(it, 8, RoundingMode.DOWN).stripTrailingZeros().toPlainString()} ${asset.symbol}" } + ?: formatPerpsExactUsdDecimal(estimatedReceive) + } else "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, fontWeight = FontWeight.W400 @@ -445,18 +469,20 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } } - Spacer(modifier = Modifier.height(4.dp)) - Row { - Text( - text = "${stringResource(R.string.PnL)}: ", - color = MixinAppTheme.colors.textAssist, - fontSize = 14.sp - ) - Text( - text = "${formatPerpsSignedRawUsdDecimal(pnl)} (${formatPerpsSignedPercent(pnlPercent, withSign = false)})", - color = pnlColor, - fontSize = 14.sp - ) + if (!isReduceMargin) { + Spacer(modifier = Modifier.height(4.dp)) + Row { + Text( + text = "${stringResource(R.string.PnL)}: ", + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp + ) + Text( + text = "${formatPerpsSignedRawUsdDecimal(pnl)} (${formatPerpsSignedPercent(pnlPercent, withSign = false)})", + color = pnlColor, + fontSize = 14.sp + ) + } } } Box(modifier = Modifier.height(20.dp)) @@ -498,11 +524,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(id = R.string.Retry), cancelAction = { - AnalyticsTracker.trackPerpsClosePreviewCancel() + if (!isReduceMargin) AnalyticsTracker.trackPerpsClosePreviewCancel() dismiss() }, confirmAction = { - AnalyticsTracker.trackPerpsClosePreviewConfirm() + if (!isReduceMargin) AnalyticsTracker.trackPerpsClosePreviewConfirm() showVerifyPinThenClose() }, ) @@ -514,11 +540,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(id = R.string.Confirm), cancelAction = { - AnalyticsTracker.trackPerpsClosePreviewCancel() + if (!isReduceMargin) AnalyticsTracker.trackPerpsClosePreviewCancel() dismiss() }, confirmAction = { - AnalyticsTracker.trackPerpsClosePreviewConfirm() + if (!isReduceMargin) AnalyticsTracker.trackPerpsClosePreviewConfirm() showVerifyPinThenClose() }, ) @@ -547,13 +573,31 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen super.onDismiss(dialog) } + private var onMarginConfirmed: (() -> Unit)? = null + + fun setOnMarginConfirmed(callback: () -> Unit): PerpsCloseBottomSheetDialogFragment { + onMarginConfirmed = callback + return this + } + private fun showVerifyPinThenClose() { + if (parentFragmentManager.findFragmentByTag(VerifyBottomSheetDialogFragment.TAG) != null) return + if (isReduceMargin && (marginAdjustmentAmount(reduceMarginAmount.orEmpty()) == null || onMarginConfirmed == null)) { + errorInfo = getString(R.string.Data_error) + step = Step.Error + return + } VerifyBottomSheetDialogFragment.newInstance( title = getString(R.string.Verify_PIN), ).apply { disableToast = true }.setOnPinSuccess { - closePosition() + if (isReduceMargin) { + onMarginConfirmed?.invoke() + dismiss() + } else { + closePosition() + } }.showNow(parentFragmentManager, VerifyBottomSheetDialogFragment.TAG) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt index 2a04732e23..1cba9fe996 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt @@ -110,6 +110,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private const val ARGS_PRICE_SCALE = "args_price_scale" private const val ARGS_PAY_URL = "args_pay_url" private const val ARGS_IS_ADD_POSITION = "args_is_add_position" + private const val ARGS_IS_ADD_MARGIN = "args_is_add_margin" fun newInstance( marketSymbol: String, @@ -126,6 +127,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm priceScale: Int = 2, payUrl: String?, isAddPosition: Boolean = false, + isAddMargin: Boolean = false, ): PerpsConfirmBottomSheetDialogFragment { return PerpsConfirmBottomSheetDialogFragment().withArgs { putString(ARGS_MARKET_SYMBOL, marketSymbol) @@ -142,6 +144,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm putInt(ARGS_PRICE_SCALE, priceScale) putString(ARGS_PAY_URL, payUrl) putBoolean(ARGS_IS_ADD_POSITION, isAddPosition) + putBoolean(ARGS_IS_ADD_MARGIN, isAddMargin) } } } @@ -193,6 +196,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private val rawLiquidationPrice by lazy { requireArguments().getString(ARGS_LIQUIDATION_PRICE) } private val priceScale by lazy { requireArguments().getInt(ARGS_PRICE_SCALE, 2) } private val isAddPosition by lazy { requireArguments().getBoolean(ARGS_IS_ADD_POSITION) } + private val isAddMargin by lazy { requireArguments().getBoolean(ARGS_IS_ADD_MARGIN) } private val payUrl by lazy { requireArguments().getString(ARGS_PAY_URL) } private val entryFiatPrice by lazy { @@ -282,9 +286,18 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Text( text = stringResource( id = when (step) { - Step.Pending -> if (isAddPosition) R.string.confirm_adding_position else R.string.confirm_opening_position - Step.Done -> if (isAddPosition) R.string.Position_Add_Submitted else R.string.Position_Submitted + Step.Pending -> when { + isAddMargin -> R.string.perps_confirm_add_margin + isAddPosition -> R.string.confirm_adding_position + else -> R.string.confirm_opening_position + } + Step.Done -> when { + isAddMargin -> R.string.perps_margin_submitted + isAddPosition -> R.string.Position_Add_Submitted + else -> R.string.Position_Submitted + } Step.Error -> when { + isAddMargin -> R.string.Failed isAddPosition && isLong -> R.string.Added_Long_Failed isAddPosition -> R.string.Added_Short_Failed isLong -> R.string.Opened_Long_Failed @@ -364,11 +377,13 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm value = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${leverage}x" ) Box(modifier = Modifier.height(6.dp)) - ProfitLossInfo( - amount = amount, - leverage = leverage, - isLong = isLong - ) + if (!isAddMargin) { + ProfitLossInfo( + amount = amount, + leverage = leverage, + isLong = isLong + ) + } Box(modifier = Modifier.height(20.dp)) PerpsInfoItem( @@ -377,86 +392,89 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm ) Box(modifier = Modifier.height(20.dp)) - PerpsInfoItem( - title = stringResource(R.string.Entry_Price).uppercase(), - value = entryFiatPrice - ) - Box(modifier = Modifier.height(20.dp)) - - takeProfitFiatPrice?.let { takeProfit -> - val tpSubValue = calculateTpSlSubValue( - targetPrice = takeProfitPrice, - entryPrice = entryPrice, - leverage = leverage, - amount = amount, - isLong = isLong, - isTakeProfit = true, - ) + if (!isAddMargin) { PerpsInfoItem( - title = stringResource(R.string.Take_Profit).uppercase(), - value = takeProfit, - subValueAnnotated = tpSubValue, - info = true, - guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_TP_SL, + title = stringResource(R.string.Entry_Price).uppercase(), + value = entryFiatPrice ) Box(modifier = Modifier.height(20.dp)) - } - stopLossFiatPrice?.let { stopLoss -> - val slSubValue = calculateTpSlSubValue( - targetPrice = stopLossPrice, - entryPrice = entryPrice, - leverage = leverage, - amount = amount, - isLong = isLong, - isTakeProfit = false, - ) + takeProfitFiatPrice?.let { takeProfit -> + val tpSubValue = calculateTpSlSubValue( + targetPrice = takeProfitPrice, + entryPrice = entryPrice, + leverage = leverage, + amount = amount, + isLong = isLong, + isTakeProfit = true, + ) + PerpsInfoItem( + title = stringResource(R.string.Take_Profit).uppercase(), + value = takeProfit, + subValueAnnotated = tpSubValue, + info = true, + guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_TP_SL, + ) + Box(modifier = Modifier.height(20.dp)) + } + + stopLossFiatPrice?.let { stopLoss -> + val slSubValue = calculateTpSlSubValue( + targetPrice = stopLossPrice, + entryPrice = entryPrice, + leverage = leverage, + amount = amount, + isLong = isLong, + isTakeProfit = false, + ) + PerpsInfoItem( + title = stringResource(R.string.Stop_Loss).uppercase(), + value = stopLoss, + subValueAnnotated = slSubValue, + info = true, + guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_TP_SL, + ) + Box(modifier = Modifier.height(20.dp)) + } + + val lossPercent = remember(leverage) { + val percent = String.format("%.2f", 100.0 / leverage) + Timber.d("LossPercent - leverage: $leverage, lossPercent: $percent") + percent + } + + val lossSubValue = if (isLong) { + val text = stringResource( + R.string.Price_Down_Loss, + lossPercent, + amount, + tokenSymbol + ) + Timber.d("LossSubValue (Long) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") + text + } else { + val text = stringResource( + R.string.Price_Up_Loss, + lossPercent, + amount, + tokenSymbol + ) + Timber.d("LossSubValue (Short) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") + text + } + PerpsInfoItem( - title = stringResource(R.string.Stop_Loss).uppercase(), - value = stopLoss, - subValueAnnotated = slSubValue, + title = stringResource(R.string.Estimated_Liquidation_Price).uppercase(), + value = liquidationPrice, + subValue = lossSubValue, info = true, - guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_TP_SL, + guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_LIQUIDATION, ) - Box(modifier = Modifier.height(20.dp)) - } - val lossPercent = remember(leverage) { - val percent = String.format("%.2f", 100.0 / leverage) - Timber.d("LossPercent - leverage: $leverage, lossPercent: $percent") - percent - } + Box(modifier = Modifier.height(20.dp)) - val lossSubValue = if (isLong) { - val text = stringResource( - R.string.Price_Down_Loss, - lossPercent, - amount, - tokenSymbol - ) - Timber.d("LossSubValue (Long) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") - text - } else { - val text = stringResource( - R.string.Price_Up_Loss, - lossPercent, - amount, - tokenSymbol - ) - Timber.d("LossSubValue (Short) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") - text } - PerpsInfoItem( - title = stringResource(R.string.Estimated_Liquidation_Price).uppercase(), - value = liquidationPrice, - subValue = lossSubValue, - info = true, - guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_LIQUIDATION, - ) - - Box(modifier = Modifier.height(20.dp)) - ItemUserContent(title = stringResource(id = R.string.Receiver).uppercase(), receiver, null) Box(modifier = Modifier.height(20.dp)) @@ -760,7 +778,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } private fun trackPreviewConfirm() { - if (isAddPosition) { + if (isAddPosition || isAddMargin) { AnalyticsTracker.trackPerpsAddPreviewConfirm() } else { AnalyticsTracker.trackPerpsOpenPreviewConfirm() @@ -768,7 +786,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } private fun trackPreviewCancel() { - if (isAddPosition) { + if (isAddPosition || isAddMargin) { AnalyticsTracker.trackPerpsAddPreviewCancel() } else { AnalyticsTracker.trackPerpsOpenPreviewCancel() @@ -776,7 +794,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } private fun trackPositionSuccess() { - if (isAddPosition) { + if (isAddPosition || isAddMargin) { AnalyticsTracker.trackPerpsAddEnd() } else { AnalyticsTracker.trackPerpsOpenEnd( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt new file mode 100644 index 0000000000..925675fc2f --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt @@ -0,0 +1,40 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import com.google.gson.JsonElement +import one.mixin.android.ui.home.web3.trade.TRADE_INPUT_MAX_DECIMAL_PLACES +import java.math.BigDecimal +import java.math.RoundingMode + +internal fun marginAdjustmentAmount(amount: String): BigDecimal? { + if (amount.length > 40 || amount.any { it !in '0'..'9' && it != '.' } || amount.count { it == '.' } > 1 || + amount.substringAfter('.', "").length > TRADE_INPUT_MAX_DECIMAL_PLACES) return null + return amount.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } +} + +internal fun marginAfterAdjustment(currentMargin: String?, amount: String, increase: Boolean, availableMargin: BigDecimal? = null): BigDecimal? { + val current = currentMargin?.toBigDecimalOrNull()?.takeIf { it >= BigDecimal.ZERO } ?: return null + val adjustment = marginAdjustmentAmount(amount) ?: return null + if (!increase && availableMargin != null && adjustment > availableMargin) return null + return (if (increase) current + adjustment else current - adjustment).takeIf { it >= BigDecimal.ZERO } +} + +internal fun availableMarginFromError(extra: JsonElement?): BigDecimal? { + val value = extra?.takeIf { it.isJsonObject }?.asJsonObject?.get("available_margin") + ?.takeIf { it.isJsonPrimitive }?.asJsonPrimitive ?: return null + if (!value.isString && !value.isNumber) return null + return value.asString.toBigDecimalOrNull()?.takeIf { it >= BigDecimal.ZERO } +} + +internal fun reduceMarginAmount(currentMargin: String?, input: String, isPercentage: Boolean): BigDecimal? { + val value = marginAdjustmentAmount(input) ?: return null + if (!isPercentage) return value + val margin = currentMargin?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null + return margin.multiply(value).movePointLeft(2) + .setScale(TRADE_INPUT_MAX_DECIMAL_PLACES, RoundingMode.DOWN) + .takeIf { it > BigDecimal.ZERO } +} + +internal fun marginReductionPercentage(currentMargin: String?, amount: BigDecimal?): BigDecimal? { + val margin = currentMargin?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null + return amount?.multiply(BigDecimal(100))?.divide(margin, TRADE_INPUT_MAX_DECIMAL_PLACES, RoundingMode.DOWN) +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..c95a36ab82 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -0,0 +1,653 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.view.View +import android.view.WindowManager +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Slider +import androidx.compose.material.SliderDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import java.math.BigDecimal +import java.math.RoundingMode +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import one.mixin.android.R +import one.mixin.android.api.request.perps.AdjustMarginRequest +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.response.perps.toPosition +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.getParcelableCompat +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.numberFormat8 +import one.mixin.android.extension.putBoolean +import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.toast +import one.mixin.android.extension.withArgs +import one.mixin.android.session.Session +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +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.TRADE_INPUT_MAX_DECIMAL_PLACES +import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment +import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.util.ErrorHandler +import one.mixin.android.util.analytics.AnalyticsTracker +import one.mixin.android.util.getMixinErrorStringByCode +import one.mixin.android.vo.safe.TokenItem +import one.mixin.android.widget.components.MixinButton + +@AndroidEntryPoint +class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + companion object { + const val TAG = "PerpsMarginBottomSheetDialogFragment" + private const val ARGS_POSITION = "args_position" + private const val ARGS_INCREASE = "args_increase" + private const val PREF_REDUCE_BY_PERCENT = "perps_reduce_margin_by_percent" + + fun newInstance(position: PerpsPositionItem, increase: Boolean) = PerpsMarginBottomSheetDialogFragment().withArgs { + putParcelable(ARGS_POSITION, position) + putBoolean(ARGS_INCREASE, increase) + } + } + + private val initialPosition by lazy { + requireNotNull(requireArguments().getParcelableCompat(ARGS_POSITION, PerpsPositionItem::class.java)) + } + private val increase by lazy { requireArguments().getBoolean(ARGS_INCREASE) } + + override fun getTheme() = R.style.AppTheme_Dialog + + override fun getBottomSheetHeight(view: View): Int = + requireContext().screenHeight() - view.getSafeAreaInsetsTop() + + override fun onStart() { + super.onStart() + dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + + @Composable + override fun ComposeContent() { + val viewModel = hiltViewModel() + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + val position by remember(initialPosition.positionId) { + viewModel.observePosition(initialPosition.positionId) + }.collectAsStateWithLifecycle(initialValue = initialPosition) + val currentPosition = position ?: initialPosition + var amount by rememberSaveable { mutableStateOf("") } + val preferences = remember { requireContext().defaultSharedPreferences } + var reduceByPercent by rememberSaveable { mutableStateOf(preferences.getBoolean(PREF_REDUCE_BY_PERCENT, true)) } + var inputIsPercentage by rememberSaveable { mutableStateOf(reduceByPercent) } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + var selectedToken by remember { mutableStateOf(null) } + var acceptedAssets by remember { mutableStateOf>(emptyList()) } + var market by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var availableMargin by remember(currentPosition.margin, currentPosition.quantity) { mutableStateOf(null) } + fun changeAmount(value: String) { + if (!loading) { + amount = value + if (!increase) inputIsPercentage = reduceByPercent + error = null + } + } + val amountValue = if (increase) marginAdjustmentAmount(amount) else reduceMarginAmount(currentPosition.margin, amount, inputIsPercentage) + val normalizedAmount = amountValue?.stripTrailingZeros()?.toPlainString().orEmpty() + val totalMargin = marginAfterAdjustment(currentPosition.margin, normalizedAmount, increase, availableMargin) + val marginValue = currentPosition.margin?.toBigDecimalOrNull()?.takeIf { it >= BigDecimal.ZERO } + val maximumReduction = marginValue?.let { margin -> availableMargin?.min(margin) ?: margin } + val exceedsReductionLimit = !increase && ( + (amountValue != null && maximumReduction != null && amountValue > maximumReduction) || + (inputIsPercentage && (amount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal(100)) + ) + val percentage = marginReductionPercentage(currentPosition.margin, amountValue) + val reduceInput = when { + reduceByPercent == inputIsPercentage -> amount + reduceByPercent -> percentage?.stripTrailingZeros()?.toPlainString().orEmpty() + else -> normalizedAmount + } + val balanceUsd = selectedToken?.let { token -> + val balance = token.balance.toBigDecimalOrNull() ?: return@let null + val price = token.priceUsd.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return@let null + balance * price + } + val insufficientBalance = increase && amountValue != null && balanceUsd != null && amountValue > balanceUsd + val isCurrentWallet = !currentPosition.walletId.isNullOrBlank() && currentPosition.walletId == Session.getAccountId() + val canSubmit = !loading && !exceedsReductionLimit && position?.state == PerpsPosition.STATE_OPEN && totalMargin != null && + isCurrentWallet && + if (increase) { + selectedToken?.assetId in acceptedAssets && balanceUsd != null && !insufficientBalance + } else { + availableMargin?.let { amountValue != null && amountValue <= it } ?: true + } + + LaunchedEffect(initialPosition.positionId, lifecycleOwner) { + market = viewModel.getMarketFromDb(initialPosition.marketId) + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + viewModel.refreshSinglePosition(initialPosition.positionId, initialPosition.walletId) + viewModel.loadMarketDetail(initialPosition.marketId, onSuccess = { market = it }, onError = {}) + delay(3_000L) + } + } + } + LaunchedEffect(increase) { + if (increase) { + AnalyticsTracker.trackPerpsAddStart(AnalyticsTracker.PerpsAddType.ADD_MARGIN) + viewModel.loadAcceptedAssets( + onSuccess = { ids -> + acceptedAssets = ids + viewModel.loadUsdTokens { tokens -> + selectedToken = tokens.filter { it.assetId in ids } + .maxByOrNull { it.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO } + } + }, + onError = { error = it }, + ) + } + } + + fun submit(submittedAmount: String) { + if (loading || position?.state != PerpsPosition.STATE_OPEN || !isCurrentWallet) return + val value = marginAdjustmentAmount(submittedAmount) ?: return + if (marginAfterAdjustment(currentPosition.margin, submittedAmount, increase, availableMargin) == null) return + val token = selectedToken + if (increase && (token == null || token.assetId !in acceptedAssets || balanceUsd == null || value > balanceUsd)) return + loading = true + isCancelable = false + error = null + scope.launch { + try { + val response = viewModel.adjustPerpsMargin( + currentPosition.positionId, + AdjustMarginRequest( + type = if (increase) "increase" else "decrease", + amount = value.stripTrailingZeros().toPlainString(), + assetId = token?.assetId.takeIf { increase }, + ), + ) + val data = response.data + if (response.isSuccess && data != null) { + if (increase && token != null) { + if (data.paymentUrl.isNullOrBlank() || (data.payAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) <= BigDecimal.ZERO) { + error = getString(R.string.Data_error) + return@launch + } + PerpsConfirmBottomSheetDialogFragment.newInstance( + marketSymbol = currentPosition.displaySymbol ?: currentPosition.tokenSymbol.orEmpty(), + marketIcon = currentPosition.iconUrl.orEmpty(), + isLong = currentPosition.side.equals("long", ignoreCase = true), + amount = data.payAmount, + leverage = currentPosition.leverage, + entryPrice = currentPosition.entryPrice, + marginAssetPrice = token.priceUsd, + tokenSymbol = token.symbol, + priceScale = currentPosition.priceScale, + payUrl = data.paymentUrl, + isAddMargin = true, + ).show(parentFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + } else { + toast(R.string.perps_margin_submitted) + viewModel.refreshSinglePosition(currentPosition.positionId, currentPosition.walletId) + currentPosition.walletId?.let { viewModel.refreshOrders(it) } + } + dismiss() + } else if (response.errorCode == 10653) { + availableMargin = availableMarginFromError(response.error?.extra) + error = if (availableMargin == null) requireContext().getMixinErrorStringByCode(response.errorCode, response.errorDescription) else null + } else { + error = if (response.isSuccess) getString(R.string.Data_error) else + requireContext().getMixinErrorStringByCode(response.errorCode, response.errorDescription) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + error = ErrorHandler.getErrorMessage(e) + } finally { + loading = false + isCancelable = true + } + } + } + + val submitCurrent by rememberUpdatedState(::submit) + MixinAppTheme { + val reductionLimitError = if (exceedsReductionLimit && maximumReduction != null) { + val limit = if (reduceByPercent) { + "${marginReductionPercentage(currentPosition.margin, maximumReduction)?.stripTrailingZeros()?.toPlainString().orEmpty()}%" + } else formatPerpsExactUsdDecimal(maximumReduction) + stringResource(R.string.perps_available_margin, limit) + } else null + val errorText = if (insufficientBalance) stringResource(R.string.insufficient_balance) else error + val onSubmit = { + if (increase) { + submit(normalizedAmount) + } else if (canSubmit && parentFragmentManager.findFragmentByTag(PerpsCloseBottomSheetDialogFragment.TAG) == null) { + keyboardController?.hide() + focusManager.clearFocus() + val approvedAmount = normalizedAmount + PerpsCloseBottomSheetDialogFragment.newInstance(currentPosition.toPosition(), reduceMarginAmount = approvedAmount) + .setOnMarginConfirmed { submitCurrent(approvedAmount) } + .show(parentFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + } + } + val actionTitle = stringResource(if (increase) R.string.Add else R.string.perps_reduce_action) + KeyboardAwareBox( + modifier = Modifier.fillMaxSize().background(MixinAppTheme.colors.background), + content = { availableHeight -> + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), + ) { + val priceScale = market?.priceScale ?: currentPosition.priceScale + val entryPrice = formatPerpsPrice(currentPosition.entryPrice, priceScale) + val currentPrice = formatPerpsPrice( + market?.last?.takeIf { it.isNotBlank() } ?: currentPosition.markPrice ?: currentPosition.entryPrice, + priceScale, + ) + val subtitle = stringResource(R.string.auto_close_subtitle_after_open, entryPrice, currentPrice) + Row(verticalAlignment = Alignment.CenterVertically) { + CoilImage( + model = currentPosition.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier.size(30.dp).clip(CircleShape), + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + if (increase) R.string.perps_add_margin_title else R.string.perps_reduce_margin_title, + stringResource(if (currentPosition.side.equals("long", true)) R.string.Long else R.string.Short), + currentPosition.tokenSymbol ?: currentPosition.displaySymbol.orEmpty(), + ), + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.W600, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = buildAnnotatedString { + append(subtitle) + addStyle(SpanStyle(color = MixinAppTheme.colors.textRemarks), 0, subtitle.length) + listOf(entryPrice, currentPrice).forEach { value -> + val start = subtitle.indexOf(value) + if (start >= 0) addStyle(SpanStyle(color = MixinAppTheme.colors.textAssist), start, start + value.length) + } + }, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + } + IconButton(onClick = { dismiss() }, enabled = !loading) { + Icon( + painterResource(R.drawable.ic_circle_close), + stringResource(R.string.close), + tint = Color.Unspecified, + modifier = Modifier.size(26.dp), + ) + } + } + Spacer(Modifier.height(16.dp)) + if (increase) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Amount), + color = MixinAppTheme.colors.textPrimary, + fontSize = 14.sp, + modifier = Modifier.weight(1f), + ) + Text( + text = selectedToken?.chainName.orEmpty(), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + ) + } + Spacer(Modifier.height(8.dp)) + InputContent( + token = selectedToken?.toSwapToken(), + text = amount, + selectClick = if (!loading) ({ + TokenListBottomSheetDialogFragment.newInstance( + fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP, + currentAssetId = selectedToken?.assetId, + ).setOnAssetClick { token -> + if (token.assetId in acceptedAssets) selectedToken = token + }.show(parentFragmentManager, TokenListBottomSheetDialogFragment.TAG) + }) else null, + onInputChanged = ::changeAmount, + readOnly = loading, + tokenIconSize = 25.dp, + inputFontSize = 24.sp, + inputFontWeight = FontWeight.W500, + autoFocus = true, + maxDecimalPlaces = TRADE_INPUT_MAX_DECIMAL_PLACES, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(R.drawable.ic_web3_wallet), + null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = selectedToken?.balance?.numberFormat8() ?: "0", + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = selectedToken?.name.orEmpty(), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } + } + } else { + PerpsReduceMarginInput( + input = reduceInput, + isPercentage = reduceByPercent, + amount = amountValue, + percentage = percentage, + margin = marginValue, + errorText = reductionLimitError, + enabled = !loading, + onInputChanged = ::changeAmount, + onToggleMode = { + amount = normalizedAmount + inputIsPercentage = false + reduceByPercent = !reduceByPercent + preferences.putBoolean(PREF_REDUCE_BY_PERCENT, reduceByPercent) + error = null + }, + ) + } + Spacer(Modifier.height(18.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + val currentMarginText = currentPosition.margin?.toBigDecimalOrNull()?.let(::formatPerpsExactUsdDecimal) ?: "-" + val validTotalMargin = totalMargin?.takeIf { !insufficientBalance && !exceedsReductionLimit } + PerpsAddInfoRow( + title = stringResource(R.string.Margin), + value = if (validTotalMargin != null) "$currentMarginText → ${formatPerpsExactUsdDecimal(validTotalMargin)}" else currentMarginText, + onTipClick = { + PerpetualGuideBottomSheetDialogFragment.newInstance(PerpetualGuideBottomSheetDialogFragment.TAB_LEVERAGE) + .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + }, + ) + Spacer(Modifier.height(16.dp)) + PerpsAddInfoRow( + title = stringResource(R.string.Liquidation_Price), + value = currentPosition.liquidationPrice?.takeIf { it.isNotBlank() }?.let { formatPerpsPrice(it, priceScale) } ?: "-", + onTipClick = { + PerpetualGuideBottomSheetDialogFragment.newInstance(PerpetualGuideBottomSheetDialogFragment.TAB_LIQUIDATION) + .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + }, + ) + } + Spacer(Modifier.height(24.dp)) + } + if (availableHeight == null) { + PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = { dismiss() }, onSubmit = onSubmit) + } + } + }, + floating = { + PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = { dismiss() }, onSubmit = onSubmit) + }, + ) + } + } + + override fun showError(error: String) = Unit +} + +@Composable +private fun PerpsMarginActions( + increase: Boolean, + actionTitle: String, + errorText: String?, + canSubmit: Boolean, + loading: Boolean, + onCancel: () -> Unit, + onSubmit: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().background(MixinAppTheme.colors.background)) { + errorText?.let { + Text( + text = it, + color = MixinAppTheme.colors.walletRed, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 12.dp, bottom = 20.dp), + ) { + MixinButton( + onClick = onCancel, + enabled = !loading, + modifier = Modifier.weight(1f).height(48.dp), + backgroundColor = MixinAppTheme.colors.backgroundGrayLight, + shape = RoundedCornerShape(32.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + Text(stringResource(R.string.Cancel), color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp) + } + MixinButton( + onClick = onSubmit, + enabled = canSubmit, + modifier = Modifier.weight(1f).height(48.dp), + backgroundColor = when { + !canSubmit -> MixinAppTheme.colors.backgroundGrayLight + increase -> MixinAppTheme.colors.walletGreen + else -> MixinAppTheme.colors.accent + }, + shape = RoundedCornerShape(32.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + if (loading) CircularProgressIndicator(Modifier.size(20.dp), color = MixinAppTheme.colors.textAssist, strokeWidth = 2.dp) + else Text(actionTitle, color = if (canSubmit) Color.White else MixinAppTheme.colors.textAssist, fontSize = 16.sp) + } + } + } +} + +@Composable +private fun PerpsReduceMarginInput( + input: String, + isPercentage: Boolean, + amount: BigDecimal?, + percentage: BigDecimal?, + margin: BigDecimal?, + errorText: String?, + enabled: Boolean, + onInputChanged: (String) -> Unit, + onToggleMode: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val inputValue = input.toBigDecimalOrNull() ?: BigDecimal.ZERO + val maximum = if (isPercentage) BigDecimal(100) else margin ?: BigDecimal.ZERO + val inputColor = when { + errorText != null -> MixinAppTheme.colors.walletRed + inputValue <= BigDecimal.ZERO -> MixinAppTheme.colors.textRemarks + else -> MixinAppTheme.colors.textPrimary + } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + fun selectPercentage(value: BigDecimal) { + val selected = if (isPercentage) value else reduceMarginAmount(margin?.toPlainString(), value.toPlainString(), true) ?: BigDecimal.ZERO + onInputChanged(selected.stripTrailingZeros().toPlainString()) + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { onInputChanged((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO).stripTrailingZeros().toPlainString()) }, + enabled = enabled && inputValue > BigDecimal.ZERO, + ) { + Icon(painterResource(R.drawable.ic_perps_minus), stringResource(R.string.perps_reduce_action), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) + } + Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + if (!isPercentage) Text(PERPS_USD_SYMBOL, fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) + BasicTextField( + value = input, + onValueChange = { value -> + if (value.length <= 40 && value.all { it in '0'..'9' || it == '.' } && value.count { it == '.' } <= 1 && + value.substringAfter('.', "").length <= TRADE_INPUT_MAX_DECIMAL_PLACES) onInputChanged(value) + }, + enabled = enabled, + singleLine = true, + modifier = Modifier.width(IntrinsicSize.Min).widthIn(min = 20.dp, max = 160.dp).focusRequester(focusRequester), + textStyle = TextStyle(fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor, textAlign = TextAlign.Center), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + cursorBrush = SolidColor(MixinAppTheme.colors.accent), + decorationBox = { field -> + if (input.isEmpty()) Text("0", fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) + field() + }, + ) + if (isPercentage) Text("%", fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) + } + IconButton( + onClick = { onInputChanged((inputValue + BigDecimal.ONE).min(maximum).stripTrailingZeros().toPlainString()) }, + enabled = enabled && inputValue < maximum, + ) { + Icon(painterResource(R.drawable.ic_perps_add), stringResource(R.string.Add), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) + } + } + Row( + modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(enabled = enabled, onClick = onToggleMode).padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isPercentage) formatPerpsExactUsdDecimal(amount) else "${percentage?.stripTrailingZeros()?.toPlainString() ?: "0"}%", + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + ) + Spacer(Modifier.width(4.dp)) + Icon( + painterResource(R.drawable.ic_switch), + stringResource(if (isPercentage) R.string.Amount else R.string.perps_percentage), + tint = MixinAppTheme.colors.textRemarks, + modifier = Modifier.size(16.dp), + ) + } + errorText?.let { + Text(it, color = MixinAppTheme.colors.walletRed, fontSize = 12.sp, textAlign = TextAlign.Center) + } + Spacer(Modifier.height(12.dp)) + Slider( + value = (percentage?.toFloat() ?: 0f).coerceIn(0f, 100f), + onValueChange = { selectPercentage(BigDecimal(it.toString()).setScale(2, RoundingMode.HALF_UP)) }, + enabled = enabled && margin != null && margin > BigDecimal.ZERO, + valueRange = 0f..100f, + modifier = Modifier.fillMaxWidth().height(32.dp), + colors = SliderDefaults.colors( + thumbColor = MixinAppTheme.colors.accent, + activeTrackColor = MixinAppTheme.colors.accent, + inactiveTrackColor = MixinAppTheme.colors.backgroundGrayLight, + ), + ) + Row(modifier = Modifier.fillMaxWidth()) { + listOf(0, 25, 50, 75, 100).forEach { value -> + Text( + text = if (isPercentage) "$value%" else formatPerpsUsdDecimal(reduceMarginAmount(margin?.toPlainString(), value.toString(), true)), + color = MixinAppTheme.colors.textAssist, + fontSize = 10.sp, + textAlign = when (value) { 0 -> TextAlign.Start; 100 -> TextAlign.End; else -> TextAlign.Center }, + modifier = Modifier.weight(1f).clickable(enabled = enabled && margin != null && margin > BigDecimal.ZERO) { + selectPercentage(BigDecimal(value)) + }.padding(vertical = 12.dp), + ) + } + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 477b89c02a..68ae495bbf 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -579,62 +579,75 @@ fun PerpsMarketDetailPage( enabled = !isAddingProcessing, onClick = { if (isAddingProcessing) return@MixinButton - isAddingProcessing = true - val activity = context as? FragmentActivity ?: run { isAddingProcessing = false; return@MixinButton } + val activity = context as? FragmentActivity ?: return@MixinButton + if (activity.supportFragmentManager.findFragmentByTag(PerpsAdjustBottomSheetDialogFragment.TAG) != null) return@MixinButton val positionForAdd = currentPosition - AnalyticsTracker.trackPerpsAddStart(AnalyticsTracker.PerpsAddType.ADD_POSITION) - PerpsAddBottomSheetDialogFragment.newInstance(positionForAdd) - .setOnDestroy { - isAddingProcessing = false + PerpsAdjustBottomSheetDialogFragment().apply { + onAddMargin = { + PerpsMarginBottomSheetDialogFragment.newInstance(positionForAdd, increase = true) + .show(activity.supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) + } + onReduceMargin = { + PerpsMarginBottomSheetDialogFragment.newInstance(positionForAdd, increase = false) + .show(activity.supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) } - .setOnAdd { token, amount, liquidationPrice -> - isAddingProcessing = false - val referencePrice = market?.last - ?: positionForAdd.markPrice - ?: positionForAdd.entryPrice - viewModel.increasePerpsPosition( - positionId = positionForAdd.positionId, - assetId = token.assetId, - amount = amount, - position = positionForAdd, - price = referencePrice.takeIf { it.isNotBlank() }, - onSuccess = { response -> - val isLong = positionForAdd.side.equals("long", ignoreCase = true) - val symbol = positionForAdd.displaySymbol - ?: market?.displaySymbol - ?: positionForAdd.tokenSymbol.orEmpty() - val iconUrl = positionForAdd.iconUrl - ?: market?.iconUrl.orEmpty() - val confirmEntryPrice = referencePrice.ifBlank { positionForAdd.entryPrice } - PerpsConfirmBottomSheetDialogFragment.newInstance( - marketSymbol = symbol, - marketIcon = iconUrl, - isLong = isLong, - amount = response.payAmount, - leverage = positionForAdd.leverage, - entryPrice = confirmEntryPrice, - marginAssetPrice = token.priceUsd, - tokenSymbol = token.symbol, - takeProfitPrice = null, - stopLossPrice = null, - liquidationPrice = liquidationPrice, - priceScale = market?.priceScale ?: positionForAdd.priceScale, - payUrl = response.paymentUrl, - isAddPosition = true, - ).show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) - }, - onError = { errorCode, errorMessage -> + onAddPosition = { + isAddingProcessing = true + AnalyticsTracker.trackPerpsAddStart(AnalyticsTracker.PerpsAddType.ADD_POSITION) + PerpsAddBottomSheetDialogFragment.newInstance(positionForAdd) + .setOnDestroy { + isAddingProcessing = false + } + .setOnAdd { token, amount, liquidationPrice -> isAddingProcessing = false - val message = if (errorCode > 0) { - context.getMixinErrorStringByCode(errorCode, errorMessage) - } else { - errorMessage - } - toast(message) - }, - ) + val referencePrice = market?.last + ?: positionForAdd.markPrice + ?: positionForAdd.entryPrice + viewModel.increasePerpsPosition( + positionId = positionForAdd.positionId, + assetId = token.assetId, + amount = amount, + position = positionForAdd, + price = referencePrice.takeIf { it.isNotBlank() }, + onSuccess = { response -> + val isLong = positionForAdd.side.equals("long", ignoreCase = true) + val symbol = positionForAdd.displaySymbol + ?: market?.displaySymbol + ?: positionForAdd.tokenSymbol.orEmpty() + val iconUrl = positionForAdd.iconUrl + ?: market?.iconUrl.orEmpty() + val confirmEntryPrice = referencePrice.ifBlank { positionForAdd.entryPrice } + PerpsConfirmBottomSheetDialogFragment.newInstance( + marketSymbol = symbol, + marketIcon = iconUrl, + isLong = isLong, + amount = response.payAmount, + leverage = positionForAdd.leverage, + entryPrice = confirmEntryPrice, + marginAssetPrice = token.priceUsd, + tokenSymbol = token.symbol, + takeProfitPrice = null, + stopLossPrice = null, + liquidationPrice = liquidationPrice, + priceScale = market?.priceScale ?: positionForAdd.priceScale, + payUrl = response.paymentUrl, + isAddPosition = true, + ).show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + }, + onError = { errorCode, errorMessage -> + isAddingProcessing = false + val message = if (errorCode > 0) { + context.getMixinErrorStringByCode(errorCode, errorMessage) + } else { + errorMessage + } + toast(message) + }, + ) + } + .show(activity.supportFragmentManager, PerpsAddBottomSheetDialogFragment.TAG) } - .show(activity.supportFragmentManager, PerpsAddBottomSheetDialogFragment.TAG) + }.show(activity.supportFragmentManager, PerpsAdjustBottomSheetDialogFragment.TAG) }, backgroundColor = MixinAppTheme.colors.walletGreen, contentColor = Color.White, @@ -642,7 +655,7 @@ fun PerpsMarketDetailPage( ) { Text( fontSize = 16.sp, - text = stringResource(R.string.add_position), + text = stringResource(R.string.perps_adjust), ) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt index d5ed106654..54d2dbafe5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt @@ -686,8 +686,11 @@ fun OpenedOrderDetailPage( stringResource(R.string.Short) } val isIncrease = openedOrder.orderType == PerpsOrder.TYPE_INCREASE + val isMargin = openedOrder.orderType == PerpsOrder.TYPE_INCREASE_MARGIN || openedOrder.orderType == PerpsOrder.TYPE_DECREASE_MARGIN val isFailed = openedOrder.status == PerpsOrder.STATUS_REJECTED val title = when { + isMargin -> stringResource(if (openedOrder.orderType == PerpsOrder.TYPE_INCREASE_MARGIN) R.string.perps_add_margin else R.string.perps_reduce_margin) + + if (isFailed) " · ${stringResource(R.string.Failed)}" else "" isIncrease && isFailed -> stringResource(if (isLong) R.string.Added_Long_Failed else R.string.Added_Short_Failed) isIncrease -> @@ -756,13 +759,13 @@ fun OpenedOrderDetailPage( verticalAlignment = Alignment.Bottom, ) { Text( - text = absQuantity.stripTrailingZeros().toPlainString(), + text = if (isMargin) openedOrder.displaySymbol ?: openedOrder.tokenSymbol.orEmpty() else absQuantity.stripTrailingZeros().toPlainString(), fontSize = 34.sp, fontWeight = FontWeight.W500, fontFamily = FontFamily(Font(R.font.mixin_font)), color = MixinAppTheme.colors.textPrimary, ) - val symbol = openedOrder.tokenSymbol?.takeIf { it.isNotBlank() } + val symbol = openedOrder.tokenSymbol?.takeIf { !isMargin && it.isNotBlank() } if (symbol != null) { Spacer(modifier = Modifier.width(4.dp)) Text( @@ -860,13 +863,13 @@ fun OpenedOrderDetailPage( Spacer(modifier = Modifier.height(20.dp)) - val amountValue = openedOrder.payAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO - PositionDetailItem( - label = stringResource(R.string.Amount).uppercase(), - value = formatPerpsUsdDecimal(amountValue) - ) - - Spacer(modifier = Modifier.height(20.dp)) + openedOrder.payAmount.toBigDecimalOrNull()?.let { amountValue -> + PositionDetailItem( + label = stringResource(R.string.Amount).uppercase(), + value = formatPerpsUsdDecimal(amountValue) + ) + Spacer(modifier = Modifier.height(20.dp)) + } } ItemWalletContent( @@ -878,7 +881,7 @@ fun OpenedOrderDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.Open_Time).uppercase(), + label = stringResource(if (isMargin) R.string.Time else R.string.Open_Time).uppercase(), value = formatDate(openedOrder.createdAt) ) } diff --git a/app/src/main/res/drawable/ic_perps_margin.xml b/app/src/main/res/drawable/ic_perps_margin.xml new file mode 100644 index 0000000000..e8d3803fdd --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_margin.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_perps_position.xml b/app/src/main/res/drawable/ic_perps_position.xml new file mode 100644 index 0000000000..390c90b642 --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_position.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4076c0ec00..e0d60ef011 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1179,4 +1179,28 @@ Cuenta Earn Saldo de Earn Ganancias de ayer + Ajustar + Ajustar margen o posición + Añadir margen + Reducir margen + Aumentar posición + Reducir posición + Añade margen para reducir el riesgo de liquidación y proteger tu posición + Retira el margen excedente para liberar fondos sin cambiar tu posición + Aumenta tu posición para ampliar las ganancias y pérdidas potenciales + Cierra parcialmente tu posición para asegurar ganancias o limitar pérdidas + ¿Añadir margen o aumentar la posición? + Añadir margen reduce el riesgo de liquidación + Aumentar la posición amplifica las ganancias y los riesgos + Si el margen es bajo, añade margen primero + Margen total + Margen disponible para retirar: %1$s + Ajuste de margen enviado + Confirmar adición de margen + ¿Reducir el margen en %1$s? Esto aumenta el riesgo de liquidación. + Añadir margen a %1$s %2$s + Reducir margen de %1$s %2$s + Reducir + Porcentaje + Confirmar reducción de margen diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 2b04d71742..8da23c8a75 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -737,4 +737,28 @@ Akun Earn Saldo Earn Pendapatan kemarin + Sesuaikan + Sesuaikan Margin atau Posisi + Tambah Margin + Kurangi Margin + Tambah Posisi + Kurangi Posisi + Tambah margin untuk menurunkan risiko likuidasi dan menjaga posisi Anda lebih aman + Tarik kelebihan margin untuk membebaskan dana sambil mempertahankan posisi Anda + Tambah posisi untuk memperbesar potensi keuntungan dan kerugian + Tutup sebagian posisi untuk mengamankan keuntungan atau membatasi kerugian + Tambah Margin atau Tambah Posisi? + Menambah margin menurunkan risiko likuidasi + Menambah posisi memperbesar keuntungan dan risiko + Jika margin rendah, tambahkan margin terlebih dahulu + Total Margin + Margin yang dapat ditarik: %1$s + Penyesuaian margin telah diajukan + Konfirmasi Penambahan Margin + Kurangi margin sebesar %1$s? Ini meningkatkan risiko likuidasi. + Tambah Margin untuk %1$s %2$s + Kurangi Margin untuk %1$s %2$s + Kurangi + Persentase + Konfirmasi Pengurangan Margin diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0c8195b144..03238e57de 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1125,4 +1125,28 @@ Earnアカウント Earn残高 昨日の収益 + 調整 + 証拠金またはポジションを調整 + 証拠金を追加 + 証拠金を減らす + ポジションを追加 + ポジションを減らす + 証拠金を追加して強制決済リスクを下げ、ポジションの安全性を高めます + ポジションを維持しながら余剰証拠金を引き出し、資金を確保します + ポジションを増やし、潜在的な利益と損失を拡大します + ポジションを一部決済して利益を確定、または損失を抑えます + 証拠金を追加?それともポジションを増やす? + 証拠金の追加は強制決済リスクを下げます + ポジションを増やすと利益とリスクが拡大します + 証拠金が不足している場合は、先に証拠金を追加してください + 証拠金合計 + 引き出し可能な証拠金:%1$s + 証拠金の調整を送信しました + 証拠金の追加を確認 + 証拠金を %1$s 減らしますか?強制決済リスクが高まります。 + %1$s %2$sの証拠金を追加 + %1$s %2$sの証拠金を減らす + 減らす + 割合 + 証拠金の減額を確認 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 4a7e92300a..cced0c31a8 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2660,4 +2660,28 @@ Earn 계정 Earn 잔액 어제 수익 + 조정 + 증거금 또는 포지션 조정 + 증거금 추가 + 증거금 감소 + 포지션 추가 + 포지션 축소 + 증거금을 추가하여 강제 청산 위험을 낮추고 포지션을 더 안전하게 유지하세요 + 포지션을 유지하면서 여유 증거금을 인출하여 자금을 확보하세요 + 포지션을 늘려 잠재 수익과 손실을 확대하세요 + 포지션을 일부 청산하여 수익을 확보하거나 손실을 줄이세요 + 증거금 추가와 포지션 추가 중 무엇을 선택할까요? + 증거금을 추가하면 강제 청산 위험이 줄어듭니다 + 포지션을 늘리면 수익과 위험이 커집니다 + 증거금이 부족하면 먼저 증거금을 추가하세요 + 총 증거금 + 인출 가능한 증거금: %1$s + 증거금 조정이 제출되었습니다 + 증거금 추가 확인 + 증거금을 %1$s 줄일까요? 강제 청산 위험이 증가합니다. + %1$s %2$s 증거금 추가 + %1$s %2$s 증거금 감소 + 감소 + 백분율 + 증거금 감소 확인 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index ef943d80fd..325dd2f7f3 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -802,4 +802,28 @@ Akaun Earn Baki Earn Pendapatan semalam + Laraskan + Laraskan Margin atau Posisi + Tambah Margin + Kurangkan Margin + Tambah Posisi + Kurangkan Posisi + Tambah margin untuk mengurangkan risiko likuidasi dan melindungi posisi anda + Keluarkan lebihan margin untuk membebaskan dana sambil mengekalkan posisi anda + Tambah posisi untuk meningkatkan potensi keuntungan dan kerugian + Tutup sebahagian posisi untuk merealisasikan keuntungan atau mengehadkan kerugian + Tambah Margin atau Tambah Posisi? + Menambah margin mengurangkan risiko likuidasi + Menambah posisi meningkatkan keuntungan dan risiko + Jika margin rendah, tambah margin dahulu + Jumlah Margin + Margin yang boleh dikeluarkan: %1$s + Pelarasan margin dihantar + Sahkan Penambahan Margin + Kurangkan margin sebanyak %1$s? Ini meningkatkan risiko likuidasi. + Tambah Margin untuk %1$s %2$s + Kurangkan Margin untuk %1$s %2$s + Kurangkan + Peratusan + Sahkan Pengurangan Margin diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5f4fef6a29..81e4a07c8e 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1120,4 +1120,28 @@ Счёт Earn Баланс Earn Доход за вчера + Изменить + Изменить маржу или позицию + Добавить маржу + Уменьшить маржу + Увеличить позицию + Уменьшить позицию + Добавьте маржу, чтобы снизить риск ликвидации и защитить позицию + Выведите избыточную маржу, чтобы освободить средства, сохранив позицию + Увеличьте позицию, чтобы увеличить потенциальную прибыль и убытки + Частично закройте позицию, чтобы зафиксировать прибыль или ограничить убытки + Добавить маржу или увеличить позицию? + Добавление маржи снижает риск ликвидации + Увеличение позиции повышает прибыль и риски + Если маржи мало, сначала добавьте маржу + Общая маржа + Маржа, доступная для вывода: %1$s + Запрос на изменение маржи отправлен + Подтвердить добавление маржи + Уменьшить маржу на %1$s? Это повысит риск ликвидации. + Добавить маржу к %1$s %2$s + Уменьшить маржу %1$s %2$s + Уменьшить + Процент + Подтвердить уменьшение маржи diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e13777b5e7..652749aedc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2608,4 +2608,28 @@ 总收益 昨日收益 收益率 + 调整 + 调整保证金或仓位 + 添加保证金 + 减少保证金 + 增加仓位 + 减少仓位 + 增加保证金,降低强平风险,让持仓更安全 + 减少多余保证金,在保持持仓的同时释放资金 + 增加仓位,放大潜在收益与亏损 + 部分平仓,锁定收益或减少亏损 + 添加保证金还是增加仓位? + 添加保证金可降低强平风险 + 加仓会放大收益和风险 + 保证金不足时,优先添加保证金 + 总保证金 + 可减少保证金:%1$s + 保证金调整已提交 + 确认添加保证金 + 减少 %1$s 保证金?这会增加强平风险。 + 为%1$s %2$s添加保证金 + 减少%1$s %2$s的保证金 + 减少 + 百分比 + 确认减少保证金 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c577fabea1..ef1a43ce18 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2661,4 +2661,28 @@ 總收益 昨日收益 收益率 + 調整 + 調整保證金或倉位 + 添加保證金 + 減少保證金 + 增加倉位 + 減少倉位 + 增加保證金,降低強平風險,讓持倉更安全 + 減少多餘保證金,在保持持倉的同時釋放資金 + 增加倉位,放大潛在收益與虧損 + 部分平倉,鎖定收益或減少虧損 + 添加保證金還是增加倉位? + 添加保證金可降低強平風險 + 加倉會放大收益和風險 + 保證金不足時,優先添加保證金 + 總保證金 + 可減少保證金:%1$s + 保證金調整已提交 + 確認添加保證金 + 減少 %1$s 保證金?這會增加強平風險。 + 為%1$s %2$s添加保證金 + 減少%1$s %2$s的保證金 + 減少 + 百分比 + 確認減少保證金 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f8a4127e0..2a298d4105 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2688,4 +2688,28 @@ Avoid transfer errors caused by complex wallet addresses. Block malicious apps from gaining unsafe permissions. No Tokens + Adjust + Adjust Margin or Position + Add Margin + Reduce Margin + Add to Position + Reduce Position + Add margin to lower liquidation risk and keep your position safer + Withdraw excess margin to free up funds while maintaining your position + Increase your position for greater potential gains and losses + Partially close to lock in profits or limit losses + Add Margin or Increase Position? + Add margin to lower liquidation risk + Increase position for more gains and losses + If margin is low, add margin first + Total Margin + Available margin: %1$s + Margin adjustment submitted + Confirm Adding Margin + Reduce margin by %1$s? This increases liquidation risk. + Add Margin to %1$s %2$s + Reduce Margin for %1$s %2$s + Reduce + Percentage + Confirm Reducing Margin diff --git a/app/src/test/java/one/mixin/android/api/request/perps/OpenOrderRequestTest.kt b/app/src/test/java/one/mixin/android/api/request/perps/OpenOrderRequestTest.kt index 755e9ec24f..ebcf29f989 100644 --- a/app/src/test/java/one/mixin/android/api/request/perps/OpenOrderRequestTest.kt +++ b/app/src/test/java/one/mixin/android/api/request/perps/OpenOrderRequestTest.kt @@ -24,6 +24,25 @@ class OpenOrderRequestTest { assertFalse(json.has("leader_position_id")) } + @Test + fun increaseUsesRequestTypeAndPreservesWeb3Destination() { + val json = Gson().toJsonTree(AdjustMarginRequest("increase", "1.00000001", "asset-id", "wallet-address")).asJsonObject + assertEquals("increase", json["type"].asString) + assertEquals("1.00000001", json["amount"].asString) + assertEquals("asset-id", json["asset_id"].asString) + assertEquals("wallet-address", json["destination"].asString) + assertFalse(json.has("quantity")) + assertFalse(json.has("leverage")) + } + + @Test + fun decreaseOmitsPaymentAssetAndDestination() { + val json = Gson().toJsonTree(AdjustMarginRequest("decrease", "2.5")).asJsonObject + assertEquals(setOf("type", "amount"), json.keySet()) + assertEquals("decrease", json["type"].asString) + assertEquals("2.5", json["amount"].asString) + } + private fun openOrderRequest(leaderPositionId: String? = null) = OpenOrderRequest( assetId = "c6d0c728-2624-429b-8e0d-d9d19b6592fa", diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt new file mode 100644 index 0000000000..99b3244555 --- /dev/null +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt @@ -0,0 +1,76 @@ +package one.mixin.android.db.perps + +import android.content.Context +import androidx.paging.PagingSource +import androidx.room3.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import one.mixin.android.api.response.perps.PerpsOrder +import one.mixin.android.db.PerpsDatabase +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PerpsOrderDaoTest { + private lateinit var database: PerpsDatabase + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), PerpsDatabase::class.java) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() = database.close() + + @Test + fun allHistoryQueriesIncludeMarginOrdersAndExcludeProcessingOrders() = runBlocking { + val dao = database.perpsOrderDao() + dao.insertAll( + listOf( + order("01", PerpsOrder.TYPE_OPEN), + order("02", PerpsOrder.TYPE_INCREASE), + order("03", PerpsOrder.TYPE_INCREASE_MARGIN), + order("04", PerpsOrder.TYPE_DECREASE_MARGIN), + order("05", PerpsOrder.TYPE_CLOSE), + order("06", PerpsOrder.TYPE_INCREASE_MARGIN).copy(status = PerpsOrder.STATUS_PROCESSING), + order("07", PerpsOrder.TYPE_DECREASE_MARGIN).copy(status = PerpsOrder.STATUS_REJECTED), + order("08", "unknown"), + order("09", PerpsOrder.TYPE_DECREASE_MARGIN).copy(marketId = "other-market"), + ), + ) + val expected = listOf("09", "07", "05", "04", "03", "02", "01") + assertEquals(expected, dao.getOrders(20).map { it.orderId }) + assertEquals(expected, dao.observeOrders(20).first().map { it.orderId }) + assertEquals(expected.drop(1), dao.getOrdersByMarket("market").map { it.orderId }) + assertEquals(listOf("03", "02"), dao.getOrders(2, "2026-09-14T00:00:04Z").map { it.orderId }) + val page = dao.getOrdersPaged().load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)) + assertEquals(expected, (page as PagingSource.LoadResult.Page).data.map { it.orderId }) + } + + private fun order(id: String, type: String) = PerpsOrder( + orderId = id, + positionId = "position", + marketId = "market", + side = "long", + orderType = type, + status = PerpsOrder.STATUS_FILLED, + leverage = 10, + quantity = "1", + payAmount = "10", + entryPrice = "100", + closePrice = "0", + realizedPnl = "0", + roe = "0", + closeReason = null, + triggerPrice = null, + createdAt = "2026-09-14T00:00:${id}Z", + updatedAt = "2026-09-14T00:00:${id}Z", + ) +} diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt new file mode 100644 index 0000000000..3dd67ad04e --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -0,0 +1,87 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import com.google.gson.JsonParser +import java.math.BigDecimal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PerpsMarginAmountTest { + @Test + fun acceptsOnlyPositiveDecimalInputWithinPrecisionLimit() { + assertEquals(BigDecimal("0.00000001"), marginAdjustmentAmount("0.00000001")) + assertEquals(BigDecimal("0.5"), marginAdjustmentAmount(".5")) + listOf("1e8", "1e100000000", "-1", "+1", "1.2.3", "0.000000001", "9".repeat(41), "NaN", "", "0").forEach { + assertNull(marginAdjustmentAmount(it)) + } + } + + @Test + fun adjustsMarginWithoutLosingDecimalPrecision() { + assertEquals(BigDecimal("10.00000002"), marginAfterAdjustment("10.00000001", "0.00000001", true)) + assertEquals(BigDecimal("10.00000000"), marginAfterAdjustment("10.00000001", "0.00000001", false)) + } + + @Test + fun reducesByPercentageOfCurrentMarginAndRefreshesTheAmount() { + assertEquals(BigDecimal("25.00000000"), reduceMarginAmount("100", "25", true)) + assertEquals(BigDecimal("20.00000000"), reduceMarginAmount("80", "25", true)) + assertEquals(BigDecimal("25"), reduceMarginAmount("80", "25", false)) + assertEquals(BigDecimal("25.00000000"), marginReductionPercentage("80", BigDecimal("20"))) + } + + @Test + fun percentageConversionRoundsDownToEightDecimals() { + val reduction = reduceMarginAmount("1", "33.33333333", true) + assertEquals(BigDecimal("0.33333333"), reduction) + assertEquals(BigDecimal("0.66666667"), marginAfterAdjustment("1", reduction!!.toPlainString(), false)) + assertNull(reduceMarginAmount("0.00000001", "25", true)) + } + + @Test + fun invalidReductionDoesNotChangeTheMargin() { + listOf("", "0", "-1", "invalid").forEach { + assertNull(reduceMarginAmount("100", it, true)) + } + val excessive = reduceMarginAmount("100", "101", true) + assertNull(marginAfterAdjustment("100", excessive!!.toPlainString(), false)) + assertNull(reduceMarginAmount(null, "25", true)) + assertNull(marginReductionPercentage("0", BigDecimal.ONE)) + } + + @Test + fun reductionPreviewRequiresAmountWithinAvailableMargin() { + assertNull(marginAfterAdjustment("100", "60", false, BigDecimal("50"))) + assertNull(marginAfterAdjustment("100", "0.00000001", false, BigDecimal.ZERO)) + assertEquals(BigDecimal("75"), marginAfterAdjustment("100", "25", false, BigDecimal("50"))) + assertEquals(BigDecimal("50"), marginAfterAdjustment("100", "50", false, BigDecimal("50"))) + assertEquals(BigDecimal("160"), marginAfterAdjustment("100", "60", true, BigDecimal("50"))) + } + + @Test + fun rejectsInvalidAmountsAndNegativeRemainingMargin() { + listOf("", "invalid", "0", "-1").forEach { amount -> + assertNull(marginAfterAdjustment("10", amount, true)) + assertNull(marginAfterAdjustment("10", amount, false)) + } + assertNull(marginAfterAdjustment("10", "10.00000001", false)) + assertNull(marginAfterAdjustment("invalid", "1", true)) + assertNull(marginAfterAdjustment(null, "1", true)) + assertNull(marginAfterAdjustment("-1", "2", true)) + } + + @Test + fun readsAvailableMarginFromDecimalStringOrNumber() { + assertEquals(BigDecimal("1.00000001"), availableMarginFromError(JsonParser.parseString("""{"available_margin":"1.00000001"}"""))) + assertEquals(BigDecimal("1.25"), availableMarginFromError(JsonParser.parseString("""{"available_margin":1.25}"""))) + assertEquals(BigDecimal.ZERO, availableMarginFromError(JsonParser.parseString("""{"available_margin":"0"}"""))) + } + + @Test + fun ignoresMissingOrMalformedAvailableMargin() { + assertNull(availableMarginFromError(null)) + listOf("null", "[]", "{}", """{"available_margin":null}""", """{"available_margin":{}}""", """{"available_margin":true}""", """{"available_margin":"NaN"}""", """{"available_margin":"-1"}""").forEach { + assertNull(availableMarginFromError(JsonParser.parseString(it))) + } + } +} From a6ae4ae29fe8d38cb920b70cc19b37f68332a69c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 15 Sep 2026 13:08:55 +0800 Subject: [PATCH 02/29] fix(perps): refine margin inputs and adjustment layout --- .../PerpsAddBottomSheetDialogFragment.kt | 48 +- .../PerpsAdjustBottomSheetDialogFragment.kt | 155 ++--- .../web3/trade/perps/PerpsMarginAmount.kt | 12 + .../PerpsMarginBottomSheetDialogFragment.kt | 531 +++++++++++------- .../main/res/drawable/ic_perps_add_margin.xml | 48 ++ .../res/drawable/ic_perps_add_position.xml | 34 ++ app/src/main/res/drawable/ic_perps_margin.xml | 13 - .../main/res/drawable/ic_perps_position.xml | 13 - .../res/drawable/ic_perps_reduce_margin.xml | 41 ++ .../res/drawable/ic_perps_reduce_position.xml | 34 ++ .../web3/trade/perps/PerpsMarginAmountTest.kt | 38 +- 11 files changed, 667 insertions(+), 300 deletions(-) create mode 100644 app/src/main/res/drawable/ic_perps_add_margin.xml create mode 100644 app/src/main/res/drawable/ic_perps_add_position.xml delete mode 100644 app/src/main/res/drawable/ic_perps_margin.xml delete mode 100644 app/src/main/res/drawable/ic_perps_position.xml create mode 100644 app/src/main/res/drawable/ic_perps_reduce_margin.xml create mode 100644 app/src/main/res/drawable/ic_perps_reduce_position.xml 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 a1c6361b71..031771daa5 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 @@ -9,6 +9,7 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup import android.view.WindowManager +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -28,6 +29,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon @@ -54,6 +57,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -607,11 +611,12 @@ private fun PerpsAddContent( .padding(horizontal = 16.dp), ) { PerpsAddInfoRow( - title = stringResource(R.string.add_position_total_size), + title = stringResource(R.string.position_size), value = listOfNotNull( formatTotalSizeValue(position.quantity, "0", position.leverage, currentPrice, position.tokenSymbol.orEmpty()), if (hasInputAmount) formatTotalSizeValue(position.quantity, amount, position.leverage, currentPrice, position.tokenSymbol.orEmpty()) else null, ).joinToString(" → "), + singleLine = false, onTipClick = { showPerpsGuide(PerpetualGuideBottomSheetDialogFragment.TAB_POSITION) }, @@ -807,16 +812,17 @@ internal fun PerpsAddInfoRow( value: String, valueColor: Color = MixinAppTheme.colors.textAssist, isLoading: Boolean = false, + singleLine: Boolean = true, onTipClick: (() -> Unit)? = null, ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().animateContentSize(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Top, ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f).clickable( + modifier = Modifier.clickable( enabled = onTipClick != null, interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -846,12 +852,36 @@ internal fun PerpsAddInfoRow( color = MixinAppTheme.colors.textAssist, ) } else { - Text( + BasicText( text = value, - fontSize = 14.sp, - color = valueColor, - textAlign = TextAlign.End, - modifier = Modifier.weight(1.5f).padding(start = 12.dp), + style = TextStyle( + fontSize = 14.sp, + color = valueColor, + textAlign = TextAlign.End, + ), + maxLines = if (singleLine) 1 else Int.MAX_VALUE, + softWrap = !singleLine, + autoSize = if (singleLine) TextAutoSize.StepBased( + minFontSize = 8.sp, + maxFontSize = 14.sp, + stepSize = 0.5.sp, + ) else null, + modifier = Modifier.weight(1f).padding(start = 12.dp), + ) + } + } +} + +@Preview(name = "Long size value", widthDp = 393, locale = "zh") +@Composable +private fun PerpsAddInfoRowPreview() { + MixinAppTheme { + Column(Modifier.background(MixinAppTheme.colors.background).padding(32.dp)) { + PerpsAddInfoRow( + title = stringResource(R.string.position_size), + value = "0.000078 BTC ($5.07) → 0.00123857 BTC ($80.51)", + singleLine = false, + onTipClick = {}, ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt index 7278dce72f..e424763a11 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt @@ -6,12 +6,12 @@ import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dagger.hilt.android.AndroidEntryPoint @@ -36,7 +37,6 @@ import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.screenHeight import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment -import one.mixin.android.ui.wallet.alert.components.cardBackground @AndroidEntryPoint class PerpsAdjustBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { @@ -56,69 +56,94 @@ class PerpsAdjustBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme @Composable override fun ComposeContent() { MixinAppTheme { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MixinAppTheme.colors.background) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - .padding(top = 16.dp, bottom = 40.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.perps_adjust_title), - fontSize = 18.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.W600, - color = MixinAppTheme.colors.textPrimary, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { dismiss() }) { - Icon( - painter = painterResource(R.drawable.ic_circle_close), - contentDescription = stringResource(R.string.close), - tint = Color.Unspecified, - modifier = Modifier.size(26.dp), - ) - } - } - Spacer(Modifier.height(20.dp)) - SectionTitle(R.string.Margin) - AdjustAction(R.string.perps_add_margin, R.string.perps_add_margin_description, R.drawable.ic_perps_margin, true) { + PerpsAdjustContent( + onDismiss = { dismiss() }, + onAddMargin = { dismiss() onAddMargin?.invoke() - } - Spacer(Modifier.height(8.dp)) - AdjustAction(R.string.perps_reduce_margin, R.string.perps_reduce_margin_description, R.drawable.ic_perps_margin, false) { + }, + onReduceMargin = { dismiss() onReduceMargin?.invoke() - } - Spacer(Modifier.height(20.dp)) - SectionTitle(R.string.perps_position) - AdjustAction(R.string.perps_add_to_position, R.string.perps_add_position_description, R.drawable.ic_perps_position, true) { + }, + onAddPosition = { dismiss() onAddPosition?.invoke() - } - Spacer(Modifier.height(32.dp)) + }, + ) + } + } + + override fun showError(error: String) = Unit +} + +@Composable +private fun PerpsAdjustContent( + onDismiss: () -> Unit, + onAddMargin: () -> Unit, + onReduceMargin: () -> Unit, + onAddPosition: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MixinAppTheme.colors.backgroundWindow) + .verticalScroll(rememberScrollState()) + .padding(bottom = 40.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 70.dp).padding(start = 20.dp, end = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.perps_adjust_title), + fontSize = 18.sp, + lineHeight = 21.sp, + letterSpacing = (-0.4).sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss) { + Icon( + painter = painterResource(R.drawable.ic_circle_close), + contentDescription = stringResource(R.string.close), + tint = Color.Unspecified, + modifier = Modifier.size(26.dp), + ) + } + } + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Spacer(Modifier.height(10.dp)) + SectionTitle(R.string.Margin) + Spacer(Modifier.height(11.dp)) + AdjustAction(R.string.perps_add_margin, R.string.perps_add_margin_description, R.drawable.ic_perps_add_margin, onAddMargin) + Spacer(Modifier.height(8.dp)) + AdjustAction(R.string.perps_reduce_margin, R.string.perps_reduce_margin_description, R.drawable.ic_perps_reduce_margin, onReduceMargin) + Spacer(Modifier.height(17.dp)) + SectionTitle(R.string.perps_position) + Spacer(Modifier.height(10.dp)) + AdjustAction(R.string.perps_add_to_position, R.string.perps_add_position_description, R.drawable.ic_perps_add_position, onAddPosition) + Spacer(Modifier.height(30.dp)) + Column( + modifier = Modifier.padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( text = stringResource(R.string.perps_margin_or_position), color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, - lineHeight = 20.sp, - modifier = Modifier.padding(horizontal = 12.dp), + lineHeight = 21.sp, ) - Spacer(Modifier.height(12.dp)) listOf(R.string.perps_margin_tip, R.string.perps_position_tip, R.string.perps_low_margin_tip).forEach { tip -> - Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("•", color = MixinAppTheme.colors.textAssist, fontSize = 14.sp) - Text(stringResource(tip), color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, lineHeight = 20.sp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("•", color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, lineHeight = 18.2.sp) + Text(stringResource(tip), color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, lineHeight = 18.2.sp) } } } } } - - override fun showError(error: String) = Unit } @Composable @@ -126,9 +151,9 @@ private fun SectionTitle(@StringRes title: Int) { Text( text = stringResource(title), fontSize = 14.sp, - lineHeight = 20.sp, - color = MixinAppTheme.colors.textPrimary, - modifier = Modifier.padding(start = 4.dp, bottom = 10.dp), + lineHeight = 17.sp, + color = MixinAppTheme.colors.textMinor, + modifier = Modifier.padding(start = 4.dp), ) } @@ -137,31 +162,31 @@ private fun AdjustAction( @StringRes title: Int, @StringRes description: Int, @DrawableRes icon: Int, - increase: Boolean, onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .background(MixinAppTheme.colors.background) .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 14.dp), + .padding(start = 20.dp, end = 16.dp, top = 13.dp, bottom = 13.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Box(modifier = Modifier.size(24.dp)) { - Icon(painterResource(icon), null, tint = MixinAppTheme.colors.textRemarks, modifier = Modifier.size(24.dp)) - Icon( - painterResource(if (increase) R.drawable.ic_perps_add else R.drawable.ic_perps_minus), - null, - tint = Color.Unspecified, - modifier = Modifier.size(13.dp).align(Alignment.BottomEnd), - ) - } + Icon(painterResource(icon), null, tint = Color.Unspecified, modifier = Modifier.padding(top = 5.dp).size(24.dp)) Column(modifier = Modifier.weight(1f)) { - Text(stringResource(title), color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, lineHeight = 20.sp) + Text(stringResource(title), color = MixinAppTheme.colors.textMinor, fontSize = 16.sp, lineHeight = 20.sp) Spacer(Modifier.height(4.dp)) - Text(stringResource(description), color = MixinAppTheme.colors.textAssist, fontSize = 14.sp, lineHeight = 18.sp) + Text(stringResource(description), color = MixinAppTheme.colors.textAssist, fontSize = 13.sp, lineHeight = 16.sp) } } } + +@Preview(name = "Adjust menu", widthDp = 375, locale = "en") +@Preview(name = "调整菜单", widthDp = 375, locale = "zh") +@Composable +private fun PerpsAdjustPreview() { + MixinAppTheme { + PerpsAdjustContent(onDismiss = {}, onAddMargin = {}, onReduceMargin = {}, onAddPosition = {}) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt index 925675fc2f..e9470a3fa5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt @@ -4,6 +4,17 @@ import com.google.gson.JsonElement import one.mixin.android.ui.home.web3.trade.TRADE_INPUT_MAX_DECIMAL_PLACES import java.math.BigDecimal import java.math.RoundingMode +import kotlin.math.abs +import kotlin.math.roundToInt + +internal fun snapMarginReductionPercentage(value: Float): Int { + val percentage = value.coerceIn(0f, 100f).roundToInt() + val marker = (percentage / 25f).roundToInt() * 25 + return if (abs(percentage - marker) <= 3) marker else percentage +} + +internal fun formatMarginAdjustmentInput(value: BigDecimal, isPercentage: Boolean): String = + value.setScale(if (isPercentage) 0 else 2, RoundingMode.DOWN).toPlainString() internal fun marginAdjustmentAmount(amount: String): BigDecimal? { if (amount.length > 40 || amount.any { it !in '0'..'9' && it != '.' } || amount.count { it == '.' } > 1 || @@ -26,6 +37,7 @@ internal fun availableMarginFromError(extra: JsonElement?): BigDecimal? { } internal fun reduceMarginAmount(currentMargin: String?, input: String, isPercentage: Boolean): BigDecimal? { + if (isPercentage && '.' in input) return null val value = marginAdjustmentAmount(input) ?: return null if (!isPercentage) return value val margin = currentMargin?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index c95a36ab82..2574ab4863 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -5,8 +5,8 @@ import android.view.WindowManager import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -41,10 +41,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource @@ -54,7 +57,10 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -88,7 +94,6 @@ import one.mixin.android.session.Session import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment 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.TRADE_INPUT_MAX_DECIMAL_PLACES import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.util.ErrorHandler @@ -166,8 +171,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val percentage = marginReductionPercentage(currentPosition.margin, amountValue) val reduceInput = when { reduceByPercent == inputIsPercentage -> amount - reduceByPercent -> percentage?.stripTrailingZeros()?.toPlainString().orEmpty() - else -> normalizedAmount + reduceByPercent -> percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString().orEmpty() + else -> amountValue?.let { formatMarginAdjustmentInput(it, false) }.orEmpty() } val balanceUsd = selectedToken?.let { token -> val balance = token.balance.toBigDecimalOrNull() ?: return@let null @@ -277,8 +282,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme MixinAppTheme { val reductionLimitError = if (exceedsReductionLimit && maximumReduction != null) { val limit = if (reduceByPercent) { - "${marginReductionPercentage(currentPosition.margin, maximumReduction)?.stripTrailingZeros()?.toPlainString().orEmpty()}%" - } else formatPerpsExactUsdDecimal(maximumReduction) + "${marginReductionPercentage(currentPosition.margin, maximumReduction)?.setScale(0, RoundingMode.DOWN)?.toPlainString().orEmpty()}%" + } else formatPerpsPrice(maximumReduction, 2) stringResource(R.string.perps_available_margin, limit) } else null val errorText = if (insufficientBalance) stringResource(R.string.insufficient_balance) else error @@ -294,190 +299,217 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme .show(parentFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) } } - val actionTitle = stringResource(if (increase) R.string.Add else R.string.perps_reduce_action) - KeyboardAwareBox( - modifier = Modifier.fillMaxSize().background(MixinAppTheme.colors.background), - content = { availableHeight -> - Column(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, top = 16.dp), - ) { - val priceScale = market?.priceScale ?: currentPosition.priceScale - val entryPrice = formatPerpsPrice(currentPosition.entryPrice, priceScale) - val currentPrice = formatPerpsPrice( - market?.last?.takeIf { it.isNotBlank() } ?: currentPosition.markPrice ?: currentPosition.entryPrice, - priceScale, + PerpsMarginContent( + position = currentPosition, + marketPrice = market?.last, + priceScale = market?.priceScale ?: currentPosition.priceScale, + increase = increase, + totalMargin = totalMargin?.takeIf { !insufficientBalance && !exceedsReductionLimit }, + errorText = errorText, + canSubmit = canSubmit, + loading = loading, + onCancel = { dismiss() }, + onSubmit = onSubmit, + onGuide = { tab -> + PerpetualGuideBottomSheetDialogFragment.newInstance(tab) + .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + }, + ) { + if (increase) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Amount), + color = MixinAppTheme.colors.textPrimary, + fontSize = 14.sp, + modifier = Modifier.weight(1f), + ) + Text( + text = selectedToken?.chainName.orEmpty(), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, ) - val subtitle = stringResource(R.string.auto_close_subtitle_after_open, entryPrice, currentPrice) - Row(verticalAlignment = Alignment.CenterVertically) { - CoilImage( - model = currentPosition.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier.size(30.dp).clip(CircleShape), - ) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource( - if (increase) R.string.perps_add_margin_title else R.string.perps_reduce_margin_title, - stringResource(if (currentPosition.side.equals("long", true)) R.string.Long else R.string.Short), - currentPosition.tokenSymbol ?: currentPosition.displaySymbol.orEmpty(), - ), - color = MixinAppTheme.colors.textPrimary, - fontSize = 16.sp, - lineHeight = 20.sp, - fontWeight = FontWeight.W600, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = buildAnnotatedString { - append(subtitle) - addStyle(SpanStyle(color = MixinAppTheme.colors.textRemarks), 0, subtitle.length) - listOf(entryPrice, currentPrice).forEach { value -> - val start = subtitle.indexOf(value) - if (start >= 0) addStyle(SpanStyle(color = MixinAppTheme.colors.textAssist), start, start + value.length) - } - }, - fontSize = 12.sp, - lineHeight = 16.sp, - ) - } - IconButton(onClick = { dismiss() }, enabled = !loading) { - Icon( - painterResource(R.drawable.ic_circle_close), - stringResource(R.string.close), - tint = Color.Unspecified, - modifier = Modifier.size(26.dp), - ) - } - } - Spacer(Modifier.height(16.dp)) - if (increase) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) - .padding(16.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.Amount), - color = MixinAppTheme.colors.textPrimary, - fontSize = 14.sp, - modifier = Modifier.weight(1f), - ) - Text( - text = selectedToken?.chainName.orEmpty(), - color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, - ) - } - Spacer(Modifier.height(8.dp)) - InputContent( - token = selectedToken?.toSwapToken(), - text = amount, - selectClick = if (!loading) ({ - TokenListBottomSheetDialogFragment.newInstance( - fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP, - currentAssetId = selectedToken?.assetId, - ).setOnAssetClick { token -> - if (token.assetId in acceptedAssets) selectedToken = token - }.show(parentFragmentManager, TokenListBottomSheetDialogFragment.TAG) - }) else null, - onInputChanged = ::changeAmount, - readOnly = loading, - tokenIconSize = 25.dp, - inputFontSize = 24.sp, - inputFontWeight = FontWeight.W500, - autoFocus = true, - maxDecimalPlaces = TRADE_INPUT_MAX_DECIMAL_PLACES, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painterResource(R.drawable.ic_web3_wallet), - null, - tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - text = selectedToken?.balance?.numberFormat8() ?: "0", - color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = selectedToken?.name.orEmpty(), - color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, - textAlign = TextAlign.End, - modifier = Modifier.weight(1f), - ) - } - } - } else { - PerpsReduceMarginInput( - input = reduceInput, - isPercentage = reduceByPercent, - amount = amountValue, - percentage = percentage, - margin = marginValue, - errorText = reductionLimitError, - enabled = !loading, - onInputChanged = ::changeAmount, - onToggleMode = { - amount = normalizedAmount - inputIsPercentage = false - reduceByPercent = !reduceByPercent - preferences.putBoolean(PREF_REDUCE_BY_PERCENT, reduceByPercent) - error = null - }, - ) - } - Spacer(Modifier.height(18.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { - val currentMarginText = currentPosition.margin?.toBigDecimalOrNull()?.let(::formatPerpsExactUsdDecimal) ?: "-" - val validTotalMargin = totalMargin?.takeIf { !insufficientBalance && !exceedsReductionLimit } - PerpsAddInfoRow( - title = stringResource(R.string.Margin), - value = if (validTotalMargin != null) "$currentMarginText → ${formatPerpsExactUsdDecimal(validTotalMargin)}" else currentMarginText, - onTipClick = { - PerpetualGuideBottomSheetDialogFragment.newInstance(PerpetualGuideBottomSheetDialogFragment.TAB_LEVERAGE) - .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) - }, - ) - Spacer(Modifier.height(16.dp)) - PerpsAddInfoRow( - title = stringResource(R.string.Liquidation_Price), - value = currentPosition.liquidationPrice?.takeIf { it.isNotBlank() }?.let { formatPerpsPrice(it, priceScale) } ?: "-", - onTipClick = { - PerpetualGuideBottomSheetDialogFragment.newInstance(PerpetualGuideBottomSheetDialogFragment.TAB_LIQUIDATION) - .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) - }, - ) - } - Spacer(Modifier.height(24.dp)) } - if (availableHeight == null) { - PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = { dismiss() }, onSubmit = onSubmit) + Spacer(Modifier.height(8.dp)) + InputContent( + token = selectedToken?.toSwapToken(), + text = amount, + selectClick = if (!loading) ({ + TokenListBottomSheetDialogFragment.newInstance( + fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP, + currentAssetId = selectedToken?.assetId, + ).setOnAssetClick { token -> + if (token.assetId in acceptedAssets) selectedToken = token + }.show(parentFragmentManager, TokenListBottomSheetDialogFragment.TAG) + }) else null, + onInputChanged = ::changeAmount, + readOnly = loading, + tokenIconSize = 25.dp, + inputFontSize = 24.sp, + inputFontWeight = FontWeight.W500, + autoFocus = true, + maxDecimalPlaces = 2, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(R.drawable.ic_web3_wallet), + null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = selectedToken?.balance?.numberFormat8() ?: "0", + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = selectedToken?.name.orEmpty(), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) } } - }, - floating = { - PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = { dismiss() }, onSubmit = onSubmit) - }, - ) + } else { + PerpsReduceMarginInput( + input = reduceInput, + isPercentage = reduceByPercent, + amount = amountValue, + percentage = percentage, + margin = marginValue, + errorText = reductionLimitError, + enabled = !loading, + onInputChanged = ::changeAmount, + onToggleMode = { + amount = amountValue?.let { formatMarginAdjustmentInput(it, false) }.orEmpty() + inputIsPercentage = false + reduceByPercent = !reduceByPercent + preferences.putBoolean(PREF_REDUCE_BY_PERCENT, reduceByPercent) + error = null + }, + ) + } + } } } override fun showError(error: String) = Unit } +@Composable +private fun PerpsMarginContent( + position: PerpsPositionItem, + marketPrice: String?, + priceScale: Int, + increase: Boolean, + totalMargin: BigDecimal?, + errorText: String?, + canSubmit: Boolean, + loading: Boolean, + onCancel: () -> Unit, + onSubmit: () -> Unit, + onGuide: (Int) -> Unit, + inputContent: @Composable () -> Unit, +) { + val actionTitle = stringResource(if (increase) R.string.Add else R.string.perps_reduce_action) + KeyboardAwareBox( + modifier = Modifier.fillMaxSize().background(MixinAppTheme.colors.background), + content = { availableHeight -> + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), + ) { + val entryPrice = formatPerpsPrice(position.entryPrice, priceScale) + val currentPrice = formatPerpsPrice( + marketPrice?.takeIf { it.isNotBlank() } ?: position.markPrice ?: position.entryPrice, + priceScale, + ) + val subtitle = stringResource(R.string.auto_close_subtitle_after_open, entryPrice, currentPrice) + Row(verticalAlignment = Alignment.CenterVertically) { + CoilImage( + model = position.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier.size(30.dp).clip(CircleShape), + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + if (increase) R.string.perps_add_margin_title else R.string.perps_reduce_margin_title, + stringResource(if (position.side.equals("long", true)) R.string.Long else R.string.Short), + position.tokenSymbol ?: position.displaySymbol.orEmpty(), + ), + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.W600, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = buildAnnotatedString { + append(subtitle) + addStyle(SpanStyle(color = MixinAppTheme.colors.textRemarks), 0, subtitle.length) + listOf(entryPrice, currentPrice).forEach { value -> + val start = subtitle.indexOf(value) + if (start >= 0) addStyle(SpanStyle(color = MixinAppTheme.colors.textAssist), start, start + value.length) + } + }, + fontSize = 12.sp, + lineHeight = 16.sp, + ) + } + IconButton(onClick = onCancel, enabled = !loading) { + Icon( + painterResource(R.drawable.ic_circle_close), + stringResource(R.string.close), + tint = Color.Unspecified, + modifier = Modifier.size(26.dp), + ) + } + } + Spacer(Modifier.height(16.dp)) + inputContent() + Spacer(Modifier.height(18.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + val currentMarginText = position.margin?.toBigDecimalOrNull()?.let { formatPerpsPrice(it, 2) } ?: "-" + PerpsAddInfoRow( + title = stringResource(R.string.Margin), + value = if (totalMargin != null) "$currentMarginText → ${formatPerpsPrice(totalMargin, 2)}" else currentMarginText, + onTipClick = { onGuide(PerpetualGuideBottomSheetDialogFragment.TAB_LEVERAGE) }, + ) + Spacer(Modifier.height(16.dp)) + PerpsAddInfoRow( + title = stringResource(R.string.Liquidation_Price), + value = position.liquidationPrice?.takeIf { it.isNotBlank() }?.let { formatPerpsPrice(it, priceScale) } ?: "-", + onTipClick = { onGuide(PerpetualGuideBottomSheetDialogFragment.TAB_LIQUIDATION) }, + ) + } + Spacer(Modifier.height(24.dp)) + } + if (availableHeight == null) { + PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = onCancel, onSubmit = onSubmit) + } + } + }, + floating = { + PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = onCancel, onSubmit = onSubmit) + }, + ) +} + @Composable private fun PerpsMarginActions( increase: Boolean, @@ -546,19 +578,28 @@ private fun PerpsReduceMarginInput( val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current val inputValue = input.toBigDecimalOrNull() ?: BigDecimal.ZERO + val sliderPercentage = (if (isPercentage) inputValue.toFloat() else percentage?.toFloat() ?: 0f).coerceIn(0f, 100f) + val activeMarkerColor = MixinAppTheme.colors.accent + val inactiveMarkerColor = MixinAppTheme.colors.textRemarks val maximum = if (isPercentage) BigDecimal(100) else margin ?: BigDecimal.ZERO val inputColor = when { errorText != null -> MixinAppTheme.colors.walletRed inputValue <= BigDecimal.ZERO -> MixinAppTheme.colors.textRemarks else -> MixinAppTheme.colors.textPrimary } + val inputTextStyle = TextStyle(fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor, textAlign = TextAlign.Center) + val textMeasurer = rememberTextMeasurer() + val placeholder = if (isPercentage) "0" else "0.00" + val inputWidth = with(LocalDensity.current) { + textMeasurer.measure(input.ifEmpty { placeholder }, style = inputTextStyle, softWrap = false).size.width.toDp() + 2.dp + } LaunchedEffect(Unit) { focusRequester.requestFocus() keyboardController?.show() } fun selectPercentage(value: BigDecimal) { val selected = if (isPercentage) value else reduceMarginAmount(margin?.toPlainString(), value.toPlainString(), true) ?: BigDecimal.ZERO - onInputChanged(selected.stripTrailingZeros().toPlainString()) + onInputChanged(formatMarginAdjustmentInput(selected, isPercentage)) } Column( modifier = Modifier @@ -570,7 +611,7 @@ private fun PerpsReduceMarginInput( ) { Row(verticalAlignment = Alignment.CenterVertically) { IconButton( - onClick = { onInputChanged((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO).stripTrailingZeros().toPlainString()) }, + onClick = { onInputChanged(formatMarginAdjustmentInput((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO), isPercentage)) }, enabled = enabled && inputValue > BigDecimal.ZERO, ) { Icon(painterResource(R.drawable.ic_perps_minus), stringResource(R.string.perps_reduce_action), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) @@ -580,24 +621,26 @@ private fun PerpsReduceMarginInput( BasicTextField( value = input, onValueChange = { value -> - if (value.length <= 40 && value.all { it in '0'..'9' || it == '.' } && value.count { it == '.' } <= 1 && - value.substringAfter('.', "").length <= TRADE_INPUT_MAX_DECIMAL_PLACES) onInputChanged(value) + if (value.length <= 40 && value.all { it in '0'..'9' || (!isPercentage && it == '.') } && value.count { it == '.' } <= 1 && + value.substringAfter('.', "").length <= 2) onInputChanged(value) }, enabled = enabled, singleLine = true, - modifier = Modifier.width(IntrinsicSize.Min).widthIn(min = 20.dp, max = 160.dp).focusRequester(focusRequester), - textStyle = TextStyle(fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor, textAlign = TextAlign.Center), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.widthIn(min = 20.dp, max = 160.dp).width(inputWidth).focusRequester(focusRequester), + textStyle = inputTextStyle, + keyboardOptions = KeyboardOptions(keyboardType = if (isPercentage) KeyboardType.Number else KeyboardType.Decimal), cursorBrush = SolidColor(MixinAppTheme.colors.accent), decorationBox = { field -> - if (input.isEmpty()) Text("0", fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) - field() + Box(contentAlignment = Alignment.Center) { + if (input.isEmpty()) Text(placeholder, style = inputTextStyle) + field() + } }, ) if (isPercentage) Text("%", fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) } IconButton( - onClick = { onInputChanged((inputValue + BigDecimal.ONE).min(maximum).stripTrailingZeros().toPlainString()) }, + onClick = { onInputChanged(formatMarginAdjustmentInput((inputValue + BigDecimal.ONE).min(maximum), isPercentage)) }, enabled = enabled && inputValue < maximum, ) { Icon(painterResource(R.drawable.ic_perps_add), stringResource(R.string.Add), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) @@ -608,7 +651,7 @@ private fun PerpsReduceMarginInput( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (isPercentage) formatPerpsExactUsdDecimal(amount) else "${percentage?.stripTrailingZeros()?.toPlainString() ?: "0"}%", + text = if (isPercentage) formatPerpsPrice(amount, 2) else "${percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString() ?: "0"}%", color = MixinAppTheme.colors.textAssist, fontSize = 12.sp, ) @@ -625,25 +668,39 @@ private fun PerpsReduceMarginInput( } Spacer(Modifier.height(12.dp)) Slider( - value = (percentage?.toFloat() ?: 0f).coerceIn(0f, 100f), - onValueChange = { selectPercentage(BigDecimal(it.toString()).setScale(2, RoundingMode.HALF_UP)) }, + value = sliderPercentage, + onValueChange = { selectPercentage(BigDecimal(snapMarginReductionPercentage(it))) }, enabled = enabled && margin != null && margin > BigDecimal.ZERO, valueRange = 0f..100f, - modifier = Modifier.fillMaxWidth().height(32.dp), + steps = 99, + modifier = Modifier.fillMaxWidth().height(32.dp).drawWithContent { + drawContent() + val trackInset = 10.dp.toPx() + for (value in 0..100 step 25) { + val fraction = if (layoutDirection == LayoutDirection.Rtl) 1f - value / 100f else value / 100f + drawCircle( + color = if (value <= sliderPercentage) activeMarkerColor else inactiveMarkerColor, + radius = 3.dp.toPx(), + center = Offset(trackInset + (size.width - 2 * trackInset) * fraction, size.height / 2), + ) + } + }, colors = SliderDefaults.colors( thumbColor = MixinAppTheme.colors.accent, activeTrackColor = MixinAppTheme.colors.accent, inactiveTrackColor = MixinAppTheme.colors.backgroundGrayLight, + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, ), ) - Row(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { listOf(0, 25, 50, 75, 100).forEach { value -> Text( - text = if (isPercentage) "$value%" else formatPerpsUsdDecimal(reduceMarginAmount(margin?.toPlainString(), value.toString(), true)), + text = if (isPercentage) "$value%" else formatPerpsPrice(reduceMarginAmount(margin?.toPlainString(), value.toString(), true), 2), color = MixinAppTheme.colors.textAssist, fontSize = 10.sp, textAlign = when (value) { 0 -> TextAlign.Start; 100 -> TextAlign.End; else -> TextAlign.Center }, - modifier = Modifier.weight(1f).clickable(enabled = enabled && margin != null && margin > BigDecimal.ZERO) { + modifier = Modifier.clickable(enabled = enabled && margin != null && margin > BigDecimal.ZERO) { selectPercentage(BigDecimal(value)) }.padding(vertical = 12.dp), ) @@ -651,3 +708,83 @@ private fun PerpsReduceMarginInput( } } } + +@Preview(name = "100%", widthDp = 360, heightDp = 560, locale = "zh") +@Composable +private fun PerpsMarginPercentagePreview() { + PerpsMarginPreviewContent("100", true) +} + +@Preview(name = "0%", widthDp = 360, heightDp = 560, locale = "zh") +@Composable +private fun PerpsMarginZeroPreview() { + PerpsMarginPreviewContent("", true) +} + +@Preview(name = "USD", widthDp = 360, heightDp = 560, locale = "zh") +@Composable +private fun PerpsMarginDollarPreview() { + PerpsMarginPreviewContent("25.00", false) +} + +@Composable +private fun PerpsMarginPreviewContent(initialInput: String, initialIsPercentage: Boolean) { + val position = remember { + PerpsPositionItem( + positionId = "preview-position", + marketId = "preview-btc", + side = "long", + quantity = "0.02", + entryPrice = "60000", + leverage = 10, + margin = "123.45678901", + liquidationPrice = "55000", + tokenSymbol = "BTC", + ) + } + var input by remember { mutableStateOf(initialInput) } + var isPercentage by remember { mutableStateOf(initialIsPercentage) } + var inputIsPercentage by remember { mutableStateOf(initialIsPercentage) } + val amount = reduceMarginAmount(position.margin, input, inputIsPercentage) + val percentage = marginReductionPercentage(position.margin, amount) + val displayInput = when { + isPercentage == inputIsPercentage -> input + isPercentage -> percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString().orEmpty() + else -> amount?.let { formatMarginAdjustmentInput(it, false) }.orEmpty() + } + val totalMargin = marginAfterAdjustment(position.margin, amount?.toPlainString().orEmpty(), false) + MixinAppTheme { + PerpsMarginContent( + position = position, + marketPrice = "65000", + priceScale = position.priceScale, + increase = false, + totalMargin = totalMargin, + errorText = null, + canSubmit = totalMargin != null, + loading = false, + onCancel = {}, + onSubmit = {}, + onGuide = {}, + ) { + PerpsReduceMarginInput( + input = displayInput, + isPercentage = isPercentage, + amount = amount, + percentage = percentage, + margin = position.margin?.toBigDecimalOrNull(), + errorText = null, + enabled = true, + onInputChanged = { + input = it + inputIsPercentage = isPercentage + }, + onToggleMode = { + input = amount?.let { formatMarginAdjustmentInput(it, false) }.orEmpty() + inputIsPercentage = false + isPercentage = !isPercentage + }, + ) + } + } +} diff --git a/app/src/main/res/drawable/ic_perps_add_margin.xml b/app/src/main/res/drawable/ic_perps_add_margin.xml new file mode 100644 index 0000000000..a71727af90 --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_add_margin.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_perps_add_position.xml b/app/src/main/res/drawable/ic_perps_add_position.xml new file mode 100644 index 0000000000..1f94a0ef1a --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_add_position.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_perps_margin.xml b/app/src/main/res/drawable/ic_perps_margin.xml deleted file mode 100644 index e8d3803fdd..0000000000 --- a/app/src/main/res/drawable/ic_perps_margin.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_perps_position.xml b/app/src/main/res/drawable/ic_perps_position.xml deleted file mode 100644 index 390c90b642..0000000000 --- a/app/src/main/res/drawable/ic_perps_position.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_perps_reduce_margin.xml b/app/src/main/res/drawable/ic_perps_reduce_margin.xml new file mode 100644 index 0000000000..9e1f8c1e2e --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_reduce_margin.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_perps_reduce_position.xml b/app/src/main/res/drawable/ic_perps_reduce_position.xml new file mode 100644 index 0000000000..f667680e14 --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_reduce_position.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt index 3dd67ad04e..ddb67ca63a 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -7,6 +7,29 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class PerpsMarginAmountTest { + @Test + fun inputKeepsWholePercentagesAndTwoDollarDecimalsWithoutExceedingTheAmount() { + assertEquals("0", formatMarginAdjustmentInput(BigDecimal.ZERO, true)) + assertEquals("100", formatMarginAdjustmentInput(BigDecimal("100"), true)) + assertEquals("0.00", formatMarginAdjustmentInput(BigDecimal.ZERO, false)) + assertEquals("1.50", formatMarginAdjustmentInput(BigDecimal("1.5"), false)) + assertEquals("5.01", formatMarginAdjustmentInput(BigDecimal("5.01253097"), false)) + assertEquals("0.99", formatMarginAdjustmentInput(BigDecimal("0.999"), false)) + } + + @Test + fun sliderSnapsNearQuarterMarksAndKeepsOtherWholePercentages() { + for (marker in 0..100 step 25) { + for (offset in -3..3) { + assertEquals(marker, snapMarginReductionPercentage((marker + offset).toFloat())) + } + } + listOf(4, 21, 29, 46, 54, 71, 79, 96).forEach { + assertEquals(it, snapMarginReductionPercentage(it.toFloat())) + } + assertEquals(42, snapMarginReductionPercentage(41.6f)) + } + @Test fun acceptsOnlyPositiveDecimalInputWithinPrecisionLimit() { assertEquals(BigDecimal("0.00000001"), marginAdjustmentAmount("0.00000001")) @@ -32,12 +55,21 @@ class PerpsMarginAmountTest { @Test fun percentageConversionRoundsDownToEightDecimals() { - val reduction = reduceMarginAmount("1", "33.33333333", true) - assertEquals(BigDecimal("0.33333333"), reduction) - assertEquals(BigDecimal("0.66666667"), marginAfterAdjustment("1", reduction!!.toPlainString(), false)) + val reduction = reduceMarginAmount("1.00000001", "33", true) + assertEquals(BigDecimal("0.33000000"), reduction) + assertEquals(BigDecimal("0.67000001"), marginAfterAdjustment("1.00000001", reduction!!.toPlainString(), false)) assertNull(reduceMarginAmount("0.00000001", "25", true)) } + @Test + fun percentageRequiresWholeNumbersWhileAmountKeepsDecimals() { + listOf("81.88", "100.0", ".5").forEach { + assertNull(reduceMarginAmount("100", it, true)) + assertEquals(BigDecimal(it), reduceMarginAmount("100", it, false)) + } + assertEquals(BigDecimal("5.01253097"), reduceMarginAmount("5.01253097", "100", true)) + } + @Test fun invalidReductionDoesNotChangeTheMargin() { listOf("", "0", "-1", "invalid").forEach { From 91205e8bf831efb6ba05ba390ad4dcbfdce8c79f Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 15 Sep 2026 16:44:59 +0800 Subject: [PATCH 03/29] feat(wallet): show maximum earn apy --- .../ui/wallet/home/WalletHomeEarnAccount.kt | 25 ++- .../home/components/WalletHomeAccountCards.kt | 159 +++++++++++------- app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-ms/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../wallet/home/WalletHomeAccountStateTest.kt | 2 +- .../wallet/home/WalletHomeCashAccountTest.kt | 4 +- .../wallet/home/WalletHomeEarnAccountTest.kt | 25 ++- 14 files changed, 156 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeEarnAccount.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeEarnAccount.kt index 28ae13822e..7bc2d3d8f0 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeEarnAccount.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/WalletHomeEarnAccount.kt @@ -6,6 +6,7 @@ import one.mixin.android.api.response.EarnProduct import one.mixin.android.extension.numberFormat2 import one.mixin.android.vo.safe.TokenItem import java.math.BigDecimal +import java.math.RoundingMode data class WalletHomeEarnAccount( @SerializedName("assetId") @@ -18,12 +19,20 @@ data class WalletHomeEarnAccount( val balanceUsd: BigDecimal, @SerializedName("earningsUsd") val earningsUsd: BigDecimal, - @SerializedName("apyText") - val apyText: String?, + @SerializedName("maxApy") + val maxApy: BigDecimal?, ) { val balanceAmountText: String get() = usdBalanceAmountText(balanceUsd) + val maxApyText: String? + get() = maxApy + ?.takeIf { it > BigDecimal.ZERO } + ?.setScale(2, RoundingMode.DOWN) + ?.stripTrailingZeros() + ?.toPlainString() + ?.let { "$it%" } + val earningsAmountText: String get() { val prefix = when { @@ -111,10 +120,20 @@ internal fun List.toWalletHomeEarnAccounts( earningsUsd = accountValues.fold(BigDecimal.ZERO) { total, (account, priceUsd) -> total + decimal(account.totalEarnings).multiply(priceUsd) }, - apyText = annualRateRange(products.flatMap { it.annualRates }), + maxApy = maxAnnualRateValue(products.flatMap { it.annualRates }) + .takeIf { it > BigDecimal.ZERO }, ) } +internal fun List.summary(): WalletHomeEarnAccount { + val first = first() + return first.copy( + balanceUsd = fold(BigDecimal.ZERO) { total, account -> total + account.balanceUsd }, + earningsUsd = fold(BigDecimal.ZERO) { total, account -> total + account.earningsUsd }, + maxApy = mapNotNull { it.maxApy }.maxOrNull(), + ) +} + internal fun earnAccountUsdBalance( account: EarnAccountSummary, priceUsd: BigDecimal, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeAccountCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeAccountCards.kt index ac2c067ac8..f2deb087e4 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeAccountCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeAccountCards.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize @@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -42,7 +44,7 @@ import one.mixin.android.ui.wallet.home.WalletHomeCashAccount import one.mixin.android.ui.wallet.home.WalletHomeState import one.mixin.android.ui.wallet.home.WalletHomeEarnAccount import one.mixin.android.ui.wallet.home.WalletAssetIcon -import java.math.BigDecimal +import one.mixin.android.ui.wallet.home.summary private val AccountCardShape = RoundedCornerShape(8.dp) private val AccountTextStyle = TextStyle( @@ -59,6 +61,7 @@ internal fun WalletHomeAccountCards( if (state.cashAccount == null) { EarnAccountCard( accounts = state.earnAccounts, + quoteColorReversed = state.quoteColorReversed, callbacks = callbacks, modifier = Modifier.padding(horizontal = 20.dp), ) @@ -80,6 +83,7 @@ internal fun WalletHomeAccountCards( ) CompactEarnAccountCard( accounts = state.earnAccounts, + quoteColorReversed = state.quoteColorReversed, callbacks = callbacks, modifier = Modifier .weight(1f) @@ -96,22 +100,26 @@ private fun CompactCashAccountCard( callbacks: WalletHomeCallbacks, modifier: Modifier, ) { - val apyColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen AccountCardSurface( modifier = modifier .clickable { callbacks.onCashClicked() }, contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), ) { - Text( - text = stringResource(R.string.cash_balance), - color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, - lineHeight = 14.sp, - fontWeight = FontWeight.W400, - style = AccountTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + modifier = Modifier.heightIn(min = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.cash_balance), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.W400, + style = AccountTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Spacer(modifier = Modifier.height(8.dp)) WalletHomeAccountBalance( balanceAmountText = account.balanceAmountText, @@ -119,22 +127,15 @@ private fun CompactCashAccountCard( ) Spacer(modifier = Modifier.height(10.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - account.apyText?.let { apyText -> - Text( - text = stringResource(R.string.cash_account_apy, apyText), - color = apyColor, - fontSize = 12.sp, - lineHeight = 14.sp, - fontWeight = FontWeight.W500, - style = AccountTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .background(apyColor.copy(alpha = 0.1f), RoundedCornerShape(4.dp)) - .padding(horizontal = 3.dp), - ) + Box(modifier = Modifier.weight(1f)) { + account.apyText?.let { apyText -> + AccountApy( + text = stringResource(R.string.cash_account_apy, apyText), + quoteColorReversed = quoteColorReversed, + ) + } } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(4.dp)) AccountArrow() } } @@ -143,6 +144,7 @@ private fun CompactCashAccountCard( @Composable private fun CompactEarnAccountCard( accounts: List, + quoteColorReversed: Boolean, callbacks: WalletHomeCallbacks, modifier: Modifier, ) { @@ -152,16 +154,21 @@ private fun CompactEarnAccountCard( .clickable { callbacks.onEarnAccountClicked() }, contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), ) { - Text( - text = stringResource(R.string.earn_balance), - color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, - lineHeight = 14.sp, - fontWeight = FontWeight.W400, - style = AccountTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.earn_balance), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.W400, + style = AccountTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(modifier = Modifier.width(4.dp)) + EarnTokenIcons(accounts) + } Spacer(modifier = Modifier.height(8.dp)) WalletHomeAccountBalance( balanceAmountText = account.balanceAmountText, @@ -169,8 +176,15 @@ private fun CompactEarnAccountCard( ) Spacer(modifier = Modifier.height(10.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - EarnTokenIcons(accounts) - Spacer(modifier = Modifier.weight(1f)) + Box(modifier = Modifier.weight(1f)) { + account.maxApyText?.let { apyText -> + AccountApy( + text = stringResource(R.string.up_to_apy, apyText), + quoteColorReversed = quoteColorReversed, + ) + } + } + Spacer(modifier = Modifier.width(4.dp)) AccountArrow() } } @@ -179,6 +193,7 @@ private fun CompactEarnAccountCard( @Composable private fun EarnAccountCard( accounts: List, + quoteColorReversed: Boolean, callbacks: WalletHomeCallbacks, modifier: Modifier, ) { @@ -202,30 +217,43 @@ private fun EarnAccountCard( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringResource(R.string.earn_balance), - color = MixinAppTheme.colors.textMinor, - fontSize = 14.sp, - lineHeight = 17.sp, - fontWeight = FontWeight.W400, - style = AccountTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Row( modifier = Modifier.weight(1f), - ) + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.earn_balance), + color = MixinAppTheme.colors.textMinor, + fontSize = 14.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.W400, + style = AccountTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(modifier = Modifier.width(4.dp)) + EarnTokenIcons(accounts) + } + Spacer(modifier = Modifier.width(4.dp)) AccountArrow() } Spacer(modifier = Modifier.height(6.dp)) Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Bottom, + verticalAlignment = Alignment.CenterVertically, ) { WalletHomeAccountBalance( balanceAmountText = account.balanceAmountText, modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.width(8.dp)) - EarnTokenIcons(accounts) + account.maxApyText?.let { apyText -> + Spacer(modifier = Modifier.width(8.dp)) + AccountApy( + text = stringResource(R.string.up_to_apy, apyText), + quoteColorReversed = quoteColorReversed, + ) + } } } } @@ -316,14 +344,23 @@ private fun EarnTokenIcons(accounts: List) { } } -private fun List.summary(): WalletHomeEarnAccount { - val first = first() - return WalletHomeEarnAccount( - assetId = first.assetId, - assetSymbol = first.assetSymbol, - iconUrl = first.iconUrl, - balanceUsd = fold(BigDecimal.ZERO) { total, account -> total + account.balanceUsd }, - earningsUsd = fold(BigDecimal.ZERO) { total, account -> total + account.earningsUsd }, - apyText = mapNotNull { it.apyText }.distinct().joinToString(" / ").takeIf { it.isNotBlank() }, +@Composable +private fun AccountApy( + text: String, + quoteColorReversed: Boolean, +) { + val color = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + Text( + text = text, + color = color, + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.W500, + style = AccountTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .background(color.copy(alpha = 0.1f), RoundedCornerShape(4.dp)) + .padding(horizontal = 3.dp, vertical = 1.dp), ) } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c78d9381ab..d9cbba34b6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1204,4 +1204,5 @@ Porcentaje Confirmar reducción de margen Precio de marca (depuración) + Hasta %1$s APY diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index fd7f36fa81..98fb599b82 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -762,4 +762,5 @@ Persentase Konfirmasi Pengurangan Margin Harga Mark (Debug) + Hingga %1$s APY diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d957f40513..172622c88d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1150,4 +1150,5 @@ 割合 証拠金の減額を確認 マーク価格(デバッグ) + 最大 %1$s APY diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1f333a1e12..f05b163c26 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2685,4 +2685,5 @@ 감소 백분율 증거금 감소 확인 + 최대 %1$s APY diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 00d3da8426..1866869d1b 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -827,4 +827,5 @@ Peratusan Sahkan Pengurangan Margin Harga Mark (Nyahpepijat) + Sehingga %1$s APY diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 0459a406d2..deada8e626 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1145,4 +1145,5 @@ Процент Подтвердить уменьшение маржи Цена маркировки (отладка) + До %1$s APY diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7d13e4c5e0..9661feac2d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2633,4 +2633,5 @@ 减少 百分比 确认减少保证金 + 最高年化 %1$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 47b0efa9ea..673d170ffa 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2686,4 +2686,5 @@ 減少 百分比 確認減少保證金 + 最高年化 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a82e94793..e5515a00a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2713,4 +2713,5 @@ Reduce Percentage Confirm Reducing Margin + Up to %1$s APY diff --git a/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeAccountStateTest.kt b/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeAccountStateTest.kt index cd75298185..3c02b88b9c 100644 --- a/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeAccountStateTest.kt +++ b/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeAccountStateTest.kt @@ -23,7 +23,7 @@ class WalletHomeAccountStateTest { iconUrl = "https://example.com/usdt.png", balanceUsd = BigDecimal("1000"), earningsUsd = BigDecimal("1.25"), - apyText = "5.00%", + maxApy = BigDecimal("5.00"), ) private val zeroBalanceEarnAccount = earnAccount.copy( balanceUsd = BigDecimal.ZERO, diff --git a/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeCashAccountTest.kt b/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeCashAccountTest.kt index 55847caa4c..80c4430a1c 100644 --- a/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeCashAccountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeCashAccountTest.kt @@ -169,7 +169,7 @@ class WalletHomeCashAccountTest { "totalTransactionCount":0, "earnAccounts":[ {"assetId":"asset-1","assetSymbol":"USD","iconUrl":"","earningsUsd":"0"}, - {"assetId":"asset-2","assetSymbol":"USD","iconUrl":"","balanceUsd":"1.25","earningsUsd":"0"} + {"assetId":"asset-2","assetSymbol":"USD","iconUrl":"","balanceUsd":"1.25","earningsUsd":"0","apyText":"3.00%-5.00%"} ] } """.trimIndent() @@ -178,6 +178,8 @@ class WalletHomeCashAccountTest { assertTrue(cache != null) assertEquals(listOf("asset-2"), cache?.toState()?.earnAccounts?.map { it.assetId }) + assertEquals(BigDecimal("1.25"), cache?.toState()?.earnAccounts?.single()?.balanceUsd) + assertNull(cache?.toState()?.earnAccounts?.single()?.maxApyText) } @Test diff --git a/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeEarnAccountTest.kt b/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeEarnAccountTest.kt index f451d9fadc..e2639ec381 100644 --- a/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeEarnAccountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/wallet/home/WalletHomeEarnAccountTest.kt @@ -221,7 +221,7 @@ class WalletHomeEarnAccountTest { assertEquals(0, account.balanceUsd.compareTo(BigDecimal("3000"))) assertEquals(0, account.earningsUsd.compareTo(BigDecimal("50"))) - assertEquals("5.00%", account.apyText) + assertEquals("5%", account.maxApyText) } @Test @@ -280,7 +280,28 @@ class WalletHomeEarnAccountTest { val firstAccount = accounts.first { it.assetId == "asset-1" } assertEquals(0, firstAccount.balanceUsd.compareTo(BigDecimal("300"))) assertEquals(0, firstAccount.earningsUsd.compareTo(BigDecimal("6"))) - assertEquals("3.00%-5.00%", firstAccount.apyText) + assertEquals("5%", firstAccount.maxApyText) + } + + @Test + fun showsHighestPositiveApyAcrossProductsAndAssets() { + val cases = listOf( + listOf(listOf("0.0365", "0.10959"), listOf("0.0730"), listOf("0.2")) to "20%", + listOf(listOf("0.0365", "0.10959"), listOf("0.0730"), listOf("invalid", "0", "-0.2")) to "10.95%", + listOf(listOf("3.65%", "10.95%"), listOf("7.30%"), emptyList()) to "10.95%", + listOf(listOf("invalid", ""), listOf("0", "-0.2"), emptyList()) to null, + ) + for ((rates, expected) in cases) { + val accounts = rates.mapIndexed { index, annualRates -> + earnProduct( + productionId = "production-$index", + assetId = if (index < 2) "asset-1" else "asset-2", + annualRates = annualRates, + ) + }.toWalletHomeEarnAccounts() + + assertEquals(expected, accounts.summary().maxApyText) + } } private fun earnProduct( From d3a72881b0991db0d5407420244f1bb5e9a8bc3c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 15 Sep 2026 18:34:49 +0800 Subject: [PATCH 04/29] feat(perps): open margin links over market details --- .../mixin/android/extension/UrlExtension.kt | 14 ++++++- .../link/LinkBottomSheetDialogFragment.kt | 2 + .../ui/home/web3/trade/perps/PerpsActivity.kt | 24 ++++++++++- .../PerpsMarginBottomSheetDialogFragment.kt | 7 +++- .../ui/wallet/WalletHomeBannerActionTarget.kt | 4 ++ .../ui/wallet/WalletHomeClassicFragment.kt | 3 +- .../android/extension/PerpsLinkLoadingTest.kt | 29 +++++++++++++ .../ui/conversation/PerpsTradeActionTest.kt | 41 +++++++++++++++++++ 8 files changed, 118 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/one/mixin/android/extension/UrlExtension.kt b/app/src/main/java/one/mixin/android/extension/UrlExtension.kt index 732349bde0..60430f15dc 100644 --- a/app/src/main/java/one/mixin/android/extension/UrlExtension.kt +++ b/app/src/main/java/one/mixin/android/extension/UrlExtension.kt @@ -396,8 +396,11 @@ internal data class PerpsTradeAction( val marketId: String?, val leaderPositionId: String?, val openPosition: PerpsOpenPositionAction? = null, + val addMargin: PerpsAddMarginAction? = null, ) +internal data class PerpsAddMarginAction(val margin: String?) + internal data class PerpsOpenPositionAction( val isLong: Boolean?, val leverage: Int?, @@ -426,10 +429,17 @@ internal fun String.toPerpsTradeAction(): PerpsTradeAction? { val hasLeaderPositionId = query.hasQueryParameter("leader_position") val leaderPositionId = query.queryParameter("leader_position") if (hasLeaderPositionId && (leaderPositionId == null || !leaderPositionId.isUUID())) return null + val isAddMarginAction = query.queryParameter("action").equals("add_margin", true) val marketId = query.queryParameter("market")?.takeIf(String::isNotBlank) - ?: return if (!hasLeaderPositionId) PerpsTradeAction(null, null) else null + ?: return if (!hasLeaderPositionId && !isAddMarginAction) PerpsTradeAction(null, null) else null if (!marketId.isUUID()) return null + if (isAddMarginAction) { + val margin = query.queryParameter("margin")?.toPerpsMarginOrNull() + if (query.hasQueryParameter("margin") && margin == null) return null + return PerpsTradeAction(marketId, leaderPositionId, addMargin = PerpsAddMarginAction(margin)) + } + val isOpenAction = query.queryParameter("action").equals("open", true) val openPosition = if (isOpenAction) { val hasSide = query.hasQueryParameter("side") @@ -521,7 +531,7 @@ private suspend fun openLocalPerpsTradeAction( context: Context, action: PerpsTradeAction, ): Boolean { - if (action.openPosition != null) return false + if (action.openPosition != null || action.addMargin != null) return false val marketId = action.marketId if (marketId == null) { diff --git a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt index c27c27ac42..441aad95f1 100644 --- a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt @@ -1161,6 +1161,8 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { market.tokenSymbol, source, leaderPositionId = action.leaderPositionId, + addMargin = action.addMargin != null, + initialMargin = action.addMargin?.margin, ) } else { val (previewMarket, preview) = perpsViewModel.prepareLinkPreview(market, openPosition, action.leaderPositionId, source) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt index 926b144655..ce728f408c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt @@ -81,6 +81,7 @@ class PerpsActivity : BaseActivity() { const val MODE_DETAIL = "detail" const val MODE_OPEN_POSITION = "open_position" + const val MODE_ADD_MARGIN = "add_margin" fun showDetail( context: Context, @@ -91,6 +92,8 @@ class PerpsActivity : BaseActivity() { source: String? = null, reuseCurrentActivity: Boolean = true, leaderPositionId: String? = null, + addMargin: Boolean = false, + initialMargin: String? = null, ) { val intent = Intent(context, PerpsActivity::class.java).apply { if (context !is Activity) { @@ -103,9 +106,10 @@ class PerpsActivity : BaseActivity() { putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) putExtra(EXTRA_MARKET_DISPLAY_SYMBOL, marketDisplaySymbol) putExtra(EXTRA_MARKET_TOKEN_SYMBOL, marketTokenSymbol) - putExtra(EXTRA_MODE, MODE_DETAIL) + putExtra(EXTRA_MODE, if (addMargin) MODE_ADD_MARGIN else MODE_DETAIL) source?.let { putExtra(EXTRA_SOURCE, it) } leaderPositionId?.let { putExtra(EXTRA_LEADER_POSITION_ID, it) } + initialMargin?.let { putExtra(EXTRA_INITIAL_MARGIN, it) } } context.startActivity(intent) } @@ -172,6 +176,7 @@ class PerpsActivity : BaseActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) (supportFragmentManager.findFragmentByTag(PerpsConfirmBottomSheetDialogFragment.FAILURE_TAG) as? PerpsConfirmBottomSheetDialogFragment)?.dismiss() + (supportFragmentManager.findFragmentByTag(PerpsMarginBottomSheetDialogFragment.TAG) as? PerpsMarginBottomSheetDialogFragment)?.dismiss() setIntent(intent) directOrderHandled = false renderPage() @@ -291,6 +296,23 @@ class PerpsActivity : BaseActivity() { val tokenSymbol = tokenSymbolExtra.ifBlank { market?.tokenSymbol.orEmpty() } showMarketDetail(marketId, marketSymbol, displaySymbol, tokenSymbol, market, source) + if (mode == MODE_ADD_MARGIN) { + val accountId = Session.getAccountId() + val position = accountId?.let { viewModel.getOpenPerpsPosition(it, marketId) } + lifecycle.withResumed { + directOrderHandled = true + currentIntent.putExtra(EXTRA_MODE, MODE_DETAIL) + when { + accountId == null -> toast(R.string.error_authentication) + position == null -> toast(R.string.error_not_found) + position.state != PerpsPosition.STATE_OPEN -> toast(R.string.error_waiting_other_orders) + supportFragmentManager.findFragmentByTag(PerpsMarginBottomSheetDialogFragment.TAG) == null -> { + PerpsMarginBottomSheetDialogFragment.newInstance(position, increase = true, initialMargin = initialMargin) + .showNow(supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) + } + } + } + } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 2574ab4863..be8d7c41c4 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -87,6 +87,7 @@ import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.putBoolean +import one.mixin.android.extension.putString import one.mixin.android.extension.screenHeight import one.mixin.android.extension.toast import one.mixin.android.extension.withArgs @@ -108,11 +109,13 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme const val TAG = "PerpsMarginBottomSheetDialogFragment" private const val ARGS_POSITION = "args_position" private const val ARGS_INCREASE = "args_increase" + private const val ARGS_INITIAL_MARGIN = "args_initial_margin" private const val PREF_REDUCE_BY_PERCENT = "perps_reduce_margin_by_percent" - fun newInstance(position: PerpsPositionItem, increase: Boolean) = PerpsMarginBottomSheetDialogFragment().withArgs { + fun newInstance(position: PerpsPositionItem, increase: Boolean, initialMargin: String? = null) = PerpsMarginBottomSheetDialogFragment().withArgs { putParcelable(ARGS_POSITION, position) putBoolean(ARGS_INCREASE, increase) + putString(ARGS_INITIAL_MARGIN, initialMargin) } } @@ -140,7 +143,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme viewModel.observePosition(initialPosition.positionId) }.collectAsStateWithLifecycle(initialValue = initialPosition) val currentPosition = position ?: initialPosition - var amount by rememberSaveable { mutableStateOf("") } + var amount by rememberSaveable { mutableStateOf(arguments?.getString(ARGS_INITIAL_MARGIN).orEmpty()) } val preferences = remember { requireContext().defaultSharedPreferences } var reduceByPercent by rememberSaveable { mutableStateOf(preferences.getBoolean(PREF_REDUCE_BY_PERCENT, true)) } var inputIsPercentage by rememberSaveable { mutableStateOf(reduceByPercent) } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt index fb787c4662..7bb7da70f2 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeBannerActionTarget.kt @@ -18,6 +18,7 @@ internal sealed interface WalletHomeBannerActionTarget { val margin: String?, val leaderPositionId: String?, ) : WalletHomeBannerActionTarget + data class PerpsAddMargin(val marketId: String, val margin: String?) : WalletHomeBannerActionTarget data object PerpsTab : WalletHomeBannerActionTarget data object Buy : WalletHomeBannerActionTarget data class Web(val url: String) : WalletHomeBannerActionTarget @@ -26,6 +27,9 @@ internal sealed interface WalletHomeBannerActionTarget { internal fun String.toClassicWalletHomeBannerActionTarget(): WalletHomeBannerActionTarget { toSpotTradeAction()?.let { return WalletHomeBannerActionTarget.SpotTrade(it) } toPerpsTradeAction()?.let { action -> + if (action.addMargin != null && action.marketId != null) { + return WalletHomeBannerActionTarget.PerpsAddMargin(action.marketId, action.addMargin.margin) + } return when (val marketId = action.marketId) { null -> WalletHomeBannerActionTarget.PerpsTab else -> action.openPosition?.let { openPosition -> diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt index cc2b0965b1..eaf2f31218 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletHomeClassicFragment.kt @@ -943,7 +943,8 @@ class WalletHomeClassicFragment : BaseFragment(R.layout.fragment_privacy_wallet) leaderPositionId = target.leaderPositionId, ) } - is WalletHomeBannerActionTarget.PerpsOpen -> { + is WalletHomeBannerActionTarget.PerpsOpen, + is WalletHomeBannerActionTarget.PerpsAddMargin -> { LinkBottomSheetDialogFragment.newInstance( url, tradeSource = AnalyticsTracker.PerpsSource.WALLET_HOME, diff --git a/app/src/test/java/one/mixin/android/extension/PerpsLinkLoadingTest.kt b/app/src/test/java/one/mixin/android/extension/PerpsLinkLoadingTest.kt index 453fb5a4ce..3e1772eaeb 100644 --- a/app/src/test/java/one/mixin/android/extension/PerpsLinkLoadingTest.kt +++ b/app/src/test/java/one/mixin/android/extension/PerpsLinkLoadingTest.kt @@ -6,7 +6,11 @@ import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity +import kotlin.test.assertEquals import kotlin.test.assertFalse @RunWith(RobolectricTestRunner::class) @@ -30,6 +34,31 @@ class PerpsLinkLoadingTest { } } + @Test + fun addMarginLinksUseTheLinkBottomSheetBeforeNavigation() = runBlocking { + for (scheme in listOf("https", "mixin")) { + for (parameters in listOf("", "&margin=10")) { + val link = "$scheme://mixin.one/trade?type=perps&market=$marketId&action=add_margin$parameters" + assertFalse(link.openLocalMixinTradeAction(ContextWrapper(null))) + } + } + } + + @Test + fun addMarginOpensMarketDetailWithOptionalAmount() { + val context = RuntimeEnvironment.getApplication() + for (margin in listOf(null, "10")) { + PerpsActivity.showDetail(context, marketId, "BTC-USDT", "BTC-USDT", "BTC", addMargin = true, initialMargin = margin) + val intent = shadowOf(context).nextStartedActivity + assertEquals(PerpsActivity::class.java.name, intent.component?.className) + assertEquals(marketId, intent.getStringExtra("extra_market_id")) + assertEquals(PerpsActivity.MODE_ADD_MARGIN, intent.getStringExtra("extra_mode")) + assertEquals(margin, intent.getStringExtra("extra_initial_margin")) + } + PerpsActivity.showDetail(context, marketId, "BTC-USDT", "BTC-USDT", "BTC") + assertEquals(PerpsActivity.MODE_DETAIL, shadowOf(context).nextStartedActivity.getStringExtra("extra_mode")) + } + private val marketId = "e015f42e-b0ff-38e7-87b1-7e8d46fea119" private val leaderId = "45d4c134-5682-4b1a-baf5-7c73b1590cc1" } diff --git a/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt b/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt index 6fefdf2506..91a6009393 100644 --- a/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt +++ b/app/src/test/java/one/mixin/android/ui/conversation/PerpsTradeActionTest.kt @@ -1,6 +1,7 @@ package one.mixin.android.ui.conversation import one.mixin.android.extension.PerpsTradeAction +import one.mixin.android.extension.PerpsAddMarginAction import one.mixin.android.extension.PerpsOpenPositionAction import one.mixin.android.extension.SpotTradeAction import one.mixin.android.extension.toPerpsTradeAction @@ -52,6 +53,46 @@ class PerpsTradeActionTest { assertEquals("e015f42e-b0ff-38e7-87b1-7e8d46fea119", action.toPerpsTradeAction()?.marketId) } + @Test + fun parsesAddMarginWithOptionalAmount() { + val marketId = "5db61a77-6efc-3b1c-ac56-8d57b07332a5" + for (scheme in listOf("https", "mixin")) { + for ((parameters, amount) in listOf("" to null, "&margin=10" to "10", "&margin=10.5000" to "10.5")) { + val link = "$scheme://mixin.one/trade?type=perps&market=$marketId&action=add_margin$parameters" + assertEquals( + PerpsTradeAction(marketId, null, addMargin = PerpsAddMarginAction(amount)), + link.toPerpsTradeAction(), + ) + } + } + assertEquals( + PerpsAddMarginAction(null), + "https://mixin.one/trade?type=perpetual&market=$marketId&action=ADD_MARGIN".toPerpsTradeAction()?.addMargin, + ) + } + + @Test + fun rejectsInvalidAddMarginParameters() { + val base = "https://mixin.one/trade?type=perps&action=add_margin" + assertNull(base.toPerpsTradeAction()) + assertNull("$base&market=".toPerpsTradeAction()) + assertNull("$base&market=invalid".toPerpsTradeAction()) + for (amount in listOf("", "0", "-1", "abc", "1e2", "0.000000001", "1".repeat(65))) { + assertNull("$base&market=5db61a77-6efc-3b1c-ac56-8d57b07332a5&margin=$amount".toPerpsTradeAction()) + } + } + + @Test + fun classicBannerKeepsAddMarginActionAndOptionalAmount() { + val marketId = "5db61a77-6efc-3b1c-ac56-8d57b07332a5" + for ((parameters, amount) in listOf("" to null, "&margin=10" to "10")) { + assertEquals( + WalletHomeBannerActionTarget.PerpsAddMargin(marketId, amount), + "https://mixin.one/trade?type=perps&market=$marketId&action=add_margin$parameters".toClassicWalletHomeBannerActionTarget(), + ) + } + } + @Test fun parsesPerpsLeaderPositionId() { val action = "https://mixin.one/trade?type=perps&market=e015f42e-b0ff-38e7-87b1-7e8d46fea119&leader_position=45d4c134-5682-4b1a-baf5-7c73b1590cc1" From d1c445946d94a98eba24b4377ebbf7ddf3c22f3f Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 15 Sep 2026 23:45:50 +0800 Subject: [PATCH 05/29] fix(analytics): separate perps position and margin events --- .../PerpsAddBottomSheetDialogFragment.kt | 8 +-- .../PerpsConfirmBottomSheetDialogFragment.kt | 18 ++++-- .../PerpsMarginBottomSheetDialogFragment.kt | 13 +++- .../util/analytics/AnalyticsTracker.kt | 64 +++++++++++++------ 4 files changed, 72 insertions(+), 31 deletions(-) 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 96e8f066df..b0a7c7499c 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 @@ -256,16 +256,16 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( currentAssetId = selectedToken?.assetId, ).setOnAssetClick { token -> selectedToken = token - AnalyticsTracker.trackPerpsAddMarginSelect(token.chainName, token.symbol) + AnalyticsTracker.trackPerpsAddPositionMarginSelect(token.chainName, token.symbol) }.show(parentFragmentManager, TokenListBottomSheetDialogFragment.TAG) }, onCancel = { dismiss() - AnalyticsTracker.trackPerpsAddCancel() + AnalyticsTracker.trackPerpsAddPositionCancel() }, onAdd = { token, amount, liquidationPrice -> onAddAction?.let { action -> - AnalyticsTracker.trackPerpsAddPreview() + AnalyticsTracker.trackPerpsAddPositionPreview() action(token, amount, liquidationPrice, market) dismiss() } @@ -996,7 +996,7 @@ internal fun FragmentActivity.showPerpsAddPosition( onOrderCreated: () -> Unit = {}, onDismiss: () -> Unit = {}, ) { - AnalyticsTracker.trackPerpsAddStart(AnalyticsTracker.PerpsAddType.ADD_POSITION) + AnalyticsTracker.trackPerpsAddPositionStart() PerpsAddBottomSheetDialogFragment.newInstance(position, initialMargin = initialMargin) .setOnDestroy(onDismiss) .setOnAdd { token, amount, liquidationPrice, latestMarket -> diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt index 66569ad8b8..45280a3245 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt @@ -904,24 +904,30 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } private fun trackPreviewConfirm() { - if (isAddPosition || isAddMargin) { - AnalyticsTracker.trackPerpsAddPreviewConfirm() + if (isAddMargin) { + AnalyticsTracker.trackPerpsAddMarginPreviewConfirm() + } else if (isAddPosition) { + AnalyticsTracker.trackPerpsAddPositionPreviewConfirm() } else { AnalyticsTracker.trackPerpsOpenPreviewConfirm() } } private fun trackPreviewCancel() { - if (isAddPosition || isAddMargin) { - AnalyticsTracker.trackPerpsAddPreviewCancel() + if (isAddMargin) { + AnalyticsTracker.trackPerpsAddMarginPreviewCancel() + } else if (isAddPosition) { + AnalyticsTracker.trackPerpsAddPositionPreviewCancel() } else { AnalyticsTracker.trackPerpsOpenPreviewCancel() } } private fun trackPositionSuccess() { - if (isAddPosition || isAddMargin) { - AnalyticsTracker.trackPerpsAddEnd() + if (isAddMargin) { + AnalyticsTracker.trackPerpsAddMarginEnd() + } else if (isAddPosition) { + AnalyticsTracker.trackPerpsAddPositionEnd() } else { AnalyticsTracker.trackPerpsOpenEnd( leverage = leverage, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index be8d7c41c4..950e9e1c96 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -204,7 +204,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } LaunchedEffect(increase) { if (increase) { - AnalyticsTracker.trackPerpsAddStart(AnalyticsTracker.PerpsAddType.ADD_MARGIN) + AnalyticsTracker.trackPerpsAddMarginStart() viewModel.loadAcceptedAssets( onSuccess = { ids -> acceptedAssets = ids @@ -224,6 +224,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme if (marginAfterAdjustment(currentPosition.margin, submittedAmount, increase, availableMargin) == null) return val token = selectedToken if (increase && (token == null || token.assetId !in acceptedAssets || balanceUsd == null || value > balanceUsd)) return + if (increase) AnalyticsTracker.trackPerpsAddMarginPreview() loading = true isCancelable = false error = null @@ -311,7 +312,10 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme errorText = errorText, canSubmit = canSubmit, loading = loading, - onCancel = { dismiss() }, + onCancel = { + if (increase) AnalyticsTracker.trackPerpsAddMarginCancel() + dismiss() + }, onSubmit = onSubmit, onGuide = { tab -> PerpetualGuideBottomSheetDialogFragment.newInstance(tab) @@ -348,7 +352,10 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP, currentAssetId = selectedToken?.assetId, ).setOnAssetClick { token -> - if (token.assetId in acceptedAssets) selectedToken = token + if (token.assetId in acceptedAssets) { + selectedToken = token + AnalyticsTracker.trackPerpsAddMarginMarginSelect(token.chainName, token.symbol) + } }.show(parentFragmentManager, TokenListBottomSheetDialogFragment.TAG) }) else null, onInputChanged = ::changeAmount, diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt index 9be9a61fdc..70e3976923 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt @@ -749,42 +749,70 @@ object AnalyticsTracker { } } - fun trackPerpsAddStart(type: String) { - logEvent("trade_perps_add_start") { - putString("type", type) + fun trackPerpsAddPositionStart() { + logEvent("trade_perps_add_position_start") { + putString("type", "add_position") } } - fun trackPerpsAddMarginSelect(chain: String?, assetSymbol: String?) { - logEvent("trade_perps_add_margin_select") { + fun trackPerpsAddPositionMarginSelect(chain: String?, assetSymbol: String?) { + logEvent("trade_perps_add_position_margin_select") { putString("chain", chain) putString("asset_symbol", assetSymbol) } } - fun trackPerpsAddPreview() { - logEvent("trade_perps_add_preview") + fun trackPerpsAddPositionPreview() { + logEvent("trade_perps_add_position_preview") + } + + fun trackPerpsAddPositionPreviewConfirm() { + logEvent("trade_perps_add_position_preview_confirm") + } + + fun trackPerpsAddPositionPreviewCancel() { + logEvent("trade_perps_add_position_preview_cancel") + } + + fun trackPerpsAddPositionEnd() { + logEvent("trade_perps_add_position_end") + } + + fun trackPerpsAddPositionCancel() { + logEvent("trade_perps_add_position_cancel") + } + + fun trackPerpsAddMarginStart() { + logEvent("trade_perps_add_margin_start") { + putString("type", "add_margin") + } + } + + fun trackPerpsAddMarginMarginSelect(chain: String?, assetSymbol: String?) { + logEvent("trade_perps_add_margin_margin_select") { + putString("chain", chain) + putString("asset_symbol", assetSymbol) + } } - fun trackPerpsAddPreviewConfirm() { - logEvent("trade_perps_add_preview_confirm") + fun trackPerpsAddMarginPreview() { + logEvent("trade_perps_add_margin_preview") } - fun trackPerpsAddPreviewCancel() { - logEvent("trade_perps_add_preview_cancel") + fun trackPerpsAddMarginPreviewConfirm() { + logEvent("trade_perps_add_margin_preview_confirm") } - fun trackPerpsAddEnd() { - logEvent("trade_perps_add_end") + fun trackPerpsAddMarginPreviewCancel() { + logEvent("trade_perps_add_margin_preview_cancel") } - fun trackPerpsAddCancel() { - logEvent("trade_perps_add_cancel") + fun trackPerpsAddMarginEnd() { + logEvent("trade_perps_add_margin_end") } - object PerpsAddType { - const val ADD_POSITION = "add_position" - const val ADD_MARGIN = "add_margin" + fun trackPerpsAddMarginCancel() { + logEvent("trade_perps_add_margin_cancel") } fun trackPerpsCloseStart(type: String) { From 872a6242b03ec9d1e624f8d3d503a8c7e15c251b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 16 Sep 2026 13:25:47 +0800 Subject: [PATCH 06/29] feat(perps): preview liquidation prices for margin adjustments --- .../mixin/android/api/service/RouteService.kt | 1 + .../trade/perps/LiquidationPriceRequest.kt | 9 ++ .../web3/trade/perps/PerpetualViewModel.kt | 3 + .../PerpsMarginBottomSheetDialogFragment.kt | 38 ++++++++- .../perps/LiquidationPriceRequestTest.kt | 84 +++++++++++++++++++ 5 files changed, 134 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/one/mixin/android/api/service/RouteService.kt b/app/src/main/java/one/mixin/android/api/service/RouteService.kt index 7632c41fc7..8d244fbbff 100644 --- a/app/src/main/java/one/mixin/android/api/service/RouteService.kt +++ b/app/src/main/java/one/mixin/android/api/service/RouteService.kt @@ -427,6 +427,7 @@ interface RouteService { @Query("side") side: String? = null, @Query("leverage") leverage: Int? = null, @Query("position_id") positionId: String? = null, + @Query("action") action: String? = null, ): MixinResponse @GET("perps/orders/accepted-assets") diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt index b4fab5d11b..f12b0926bb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt @@ -15,6 +15,8 @@ internal sealed interface LiquidationPriceResult { data class LimitExceeded(val limit: LiquidationPriceLimit) : LiquidationPriceResult + data class MarginExceeded(val availableMargin: BigDecimal?) : LiquidationPriceResult + data object Retry : LiquidationPriceResult data object Failure : LiquidationPriceResult @@ -24,10 +26,12 @@ internal fun liquidationPriceResult( price: String?, errorCode: Int?, limit: LiquidationPriceLimit = LiquidationPriceLimit(null, null), + availableMargin: BigDecimal? = null, ): LiquidationPriceResult { val validPrice = price?.takeIf { it.isNotBlank() } return when { validPrice != null -> LiquidationPriceResult.Success(validPrice) + errorCode == 10653 -> LiquidationPriceResult.MarginExceeded(availableMargin) errorCode == ErrorHandler.PERPS_POSITION_SIZE_EXCEEDS_LEVERAGE_LIMIT -> { LiquidationPriceResult.LimitExceeded(limit) } @@ -65,6 +69,7 @@ internal fun shouldRequestLiquidationPrice( internal suspend fun requestLiquidationPrice( retryDelayMillis: Long = 1000L, onLimitExceeded: (LiquidationPriceLimit) -> Unit = {}, + onMarginExceeded: (BigDecimal?) -> Unit = {}, request: suspend () -> LiquidationPriceResult, ): String? { while (true) { @@ -74,6 +79,10 @@ internal suspend fun requestLiquidationPrice( onLimitExceeded(result.limit) return null } + is LiquidationPriceResult.MarginExceeded -> { + onMarginExceeded(result.availableMargin) + return null + } LiquidationPriceResult.Retry -> delay(retryDelayMillis) is LiquidationPriceResult.Success -> return result.price } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index 8b36f2145d..84ccf3bac6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -391,6 +391,7 @@ class PerpetualViewModel @Inject constructor( side: String? = null, leverage: Int? = null, positionId: String? = null, + action: String = if (positionId == null) "open" else "increase_position", ): LiquidationPriceResult { return try { val response = withContext(Dispatchers.IO) { @@ -400,6 +401,7 @@ class PerpetualViewModel @Inject constructor( side = side, leverage = leverage, positionId = positionId, + action = action, ) } if (response.isSuccess) { @@ -413,6 +415,7 @@ class PerpetualViewModel @Inject constructor( price = null, errorCode = response.errorCode, limit = parseLiquidationPriceLimit(response.error?.extra), + availableMargin = availableMarginFromError(response.error?.extra), ) } } catch (e: CancellationException) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 950e9e1c96..fecf4f864f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -164,6 +164,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } val amountValue = if (increase) marginAdjustmentAmount(amount) else reduceMarginAmount(currentPosition.margin, amount, inputIsPercentage) val normalizedAmount = amountValue?.stripTrailingZeros()?.toPlainString().orEmpty() + var remoteLiquidationPrice by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } + var isLiquidationLoading by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(false) } val totalMargin = marginAfterAdjustment(currentPosition.margin, normalizedAmount, increase, availableMargin) val marginValue = currentPosition.margin?.toBigDecimalOrNull()?.takeIf { it >= BigDecimal.ZERO } val maximumReduction = marginValue?.let { margin -> availableMargin?.min(margin) ?: margin } @@ -185,6 +187,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val insufficientBalance = increase && amountValue != null && balanceUsd != null && amountValue > balanceUsd val isCurrentWallet = !currentPosition.walletId.isNullOrBlank() && currentPosition.walletId == Session.getAccountId() val canSubmit = !loading && !exceedsReductionLimit && position?.state == PerpsPosition.STATE_OPEN && totalMargin != null && + !isLiquidationLoading && remoteLiquidationPrice != null && isCurrentWallet && if (increase) { selectedToken?.assetId in acceptedAssets && balanceUsd != null && !insufficientBalance @@ -192,6 +195,29 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme availableMargin?.let { amountValue != null && amountValue <= it } ?: true } + LaunchedEffect(normalizedAmount, increase, currentPosition.updatedAt, exceedsReductionLimit) { + remoteLiquidationPrice = null + isLiquidationLoading = false + if (totalMargin == null || exceedsReductionLimit) return@LaunchedEffect + isLiquidationLoading = true + try { + delay(200L) + remoteLiquidationPrice = requestLiquidationPrice( + onMarginExceeded = { + availableMargin = it + error = if (it == null) getString(R.string.Data_error) else null + }, + ) { + viewModel.estimateLiquidationPrice( + amount = normalizedAmount, + positionId = currentPosition.positionId, + action = if (increase) "increase_margin" else "decrease_margin", + ) + } + } finally { + isLiquidationLoading = false + } + } LaunchedEffect(initialPosition.positionId, lifecycleOwner) { market = viewModel.getMarketFromDb(initialPosition.marketId) lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -309,6 +335,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme priceScale = market?.priceScale ?: currentPosition.priceScale, increase = increase, totalMargin = totalMargin?.takeIf { !insufficientBalance && !exceedsReductionLimit }, + estimatedLiquidationPrice = remoteLiquidationPrice?.takeIf { !insufficientBalance && !exceedsReductionLimit }, + isLiquidationLoading = isLiquidationLoading, errorText = errorText, canSubmit = canSubmit, loading = loading, @@ -422,6 +450,8 @@ private fun PerpsMarginContent( priceScale: Int, increase: Boolean, totalMargin: BigDecimal?, + estimatedLiquidationPrice: String?, + isLiquidationLoading: Boolean, errorText: String?, canSubmit: Boolean, loading: Boolean, @@ -503,7 +533,11 @@ private fun PerpsMarginContent( Spacer(Modifier.height(16.dp)) PerpsAddInfoRow( title = stringResource(R.string.Liquidation_Price), - value = position.liquidationPrice?.takeIf { it.isNotBlank() }?.let { formatPerpsPrice(it, priceScale) } ?: "-", + value = listOfNotNull( + position.liquidationPrice?.takeIf { it.isNotBlank() }?.let { formatPerpsPrice(it, priceScale) } ?: "-", + estimatedLiquidationPrice?.let { formatPerpsPrice(it, priceScale) }, + ).joinToString(" → "), + isLoading = isLiquidationLoading, onTipClick = { onGuide(PerpetualGuideBottomSheetDialogFragment.TAB_LIQUIDATION) }, ) } @@ -770,6 +804,8 @@ private fun PerpsMarginPreviewContent(initialInput: String, initialIsPercentage: priceScale = position.priceScale, increase = false, totalMargin = totalMargin, + estimatedLiquidationPrice = null, + isLiquidationLoading = false, errorText = null, canSubmit = totalMargin != null, loading = false, diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt index 4c65ab5887..dd69ff121a 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt @@ -2,14 +2,98 @@ package one.mixin.android.ui.home.web3.trade.perps import com.google.gson.JsonParser import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import one.mixin.android.api.service.RouteService import one.mixin.android.util.ErrorHandler import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory import java.math.BigDecimal class LiquidationPriceRequestTest { + @Test + fun sendsActionAndOnlyTheParametersForEachScenario() = runBlocking { + val requests = mutableListOf() + val client = OkHttpClient.Builder().addInterceptor { chain -> + requests += chain.request() + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("""{"data":{"liquidation_price":"123.45"}}""".toResponseBody()) + .build() + }.build() + val service = Retrofit.Builder() + .baseUrl("https://example.com/") + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(RouteService::class.java) + + listOf( + "open" to false, + "increase_position" to true, + "increase_margin" to true, + "decrease_margin" to true, + null to false, + null to true, + ).forEach { (action, hasPosition) -> + val response = service.getPerpsLiquidationPrice( + marketId = "market".takeUnless { hasPosition }, + amount = "10.25", + side = "long".takeUnless { hasPosition }, + leverage = 10.takeUnless { hasPosition }, + positionId = "position".takeIf { hasPosition }, + action = action, + ) + val url = requests.last().url + val expected = mutableMapOf("amount" to "10.25") + action?.let { expected["action"] = it } + if (hasPosition) { + expected["position_id"] = "position" + } else { + expected.putAll(mapOf("market_id" to "market", "side" to "long", "leverage" to "10")) + } + assertEquals("GET", requests.last().method) + assertEquals("/perps/markets/liquidation-price", url.encodedPath) + assertEquals(expected, url.queryParameterNames.associateWith { url.queryParameter(it) }) + assertEquals("123.45", response.data?.liquidationPrice) + } + } + + @Test + fun marginLimitStopsAndReturnsAvailableMarginIncludingZeroOrMissing() = runBlocking { + listOf(BigDecimal("2.50"), BigDecimal.ZERO, null).forEach { available -> + var requestCount = 0 + var callbackCount = 0 + val result = liquidationPriceResult(price = null, errorCode = 10653, availableMargin = available) + assertEquals(LiquidationPriceResult.MarginExceeded(available), result) + + val price = requestLiquidationPrice( + retryDelayMillis = 0L, + onMarginExceeded = { + callbackCount += 1 + assertEquals(available, it) + }, + ) { + requestCount += 1 + result + } + + assertNull(price) + assertEquals(1, requestCount) + assertEquals(1, callbackCount) + } + } + @Test fun nonServerErrorStopsAfterOneRequest() = runBlocking { var requestCount = 0 From 37384891446e8532f20c6384420533b1e90314ba Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 16 Sep 2026 13:32:55 +0800 Subject: [PATCH 07/29] chore: fix nullability and deprecated api warnings --- .../mixin/android/db/converter/AssetChangeListConverter.kt | 2 +- .../mixin/android/db/converter/DepositEntryListConverter.kt | 2 +- .../web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt | 3 ++- .../java/one/mixin/android/ui/transfer/TransferInserter.kt | 2 +- app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt | 2 +- .../repository/TokenRepositoryWalletTransactionTest.kt | 4 ++-- .../one/mixin/android/util/ErrorHandlerDatabaseErrorTest.kt | 1 + app/src/test/java/one/mixin/android/util/SerializationTest.kt | 2 -- app/src/test/java/one/mixin/android/util/URLDecoderTest.kt | 3 +++ .../one/mixin/android/widget/linktext/AutoLinkTextViewTest.kt | 2 +- 10 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/converter/AssetChangeListConverter.kt b/app/src/main/java/one/mixin/android/db/converter/AssetChangeListConverter.kt index 58e352bf86..0b2ac483dd 100644 --- a/app/src/main/java/one/mixin/android/db/converter/AssetChangeListConverter.kt +++ b/app/src/main/java/one/mixin/android/db/converter/AssetChangeListConverter.kt @@ -9,7 +9,7 @@ class AssetChangeListConverter { private val gson = Gson() @ColumnTypeConverter - fun fromAssetChangeList(value: List?): String { + fun fromAssetChangeList(value: List?): String? { return if (value == null) { "[]" } else { diff --git a/app/src/main/java/one/mixin/android/db/converter/DepositEntryListConverter.kt b/app/src/main/java/one/mixin/android/db/converter/DepositEntryListConverter.kt index e7cf72a3ca..bb3dc881b4 100644 --- a/app/src/main/java/one/mixin/android/db/converter/DepositEntryListConverter.kt +++ b/app/src/main/java/one/mixin/android/db/converter/DepositEntryListConverter.kt @@ -17,7 +17,7 @@ class DepositEntryListConverter { } @ColumnTypeConverter - fun converterDate(list: List?): String = + fun converterDate(list: List?): String? = if (list.isNullOrEmpty()) { "" } else { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index fecf4f864f..943563384b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -129,6 +129,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme override fun getBottomSheetHeight(view: View): Int = requireContext().screenHeight() - view.getSafeAreaInsetsTop() + @Suppress("DEPRECATION") override fun onStart() { super.onStart() dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) @@ -287,7 +288,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } else { toast(R.string.perps_margin_submitted) viewModel.refreshSinglePosition(currentPosition.positionId, currentPosition.walletId) - currentPosition.walletId?.let { viewModel.refreshOrders(it) } + viewModel.refreshOrders(currentPosition.walletId) } dismiss() } else if (response.errorCode == 10653) { diff --git a/app/src/main/java/one/mixin/android/ui/transfer/TransferInserter.kt b/app/src/main/java/one/mixin/android/ui/transfer/TransferInserter.kt index f74fa6e521..15040ef5fb 100644 --- a/app/src/main/java/one/mixin/android/ui/transfer/TransferInserter.kt +++ b/app/src/main/java/one/mixin/android/ui/transfer/TransferInserter.kt @@ -547,7 +547,7 @@ class TransferInserter(val db: MixinDatabase) { if (depositEntries == null) { stmt.bindNull(16) } else { - stmt.bindString(16, depositEntryListConverter.converterDate(depositEntries)) + stmt.bindString(16, depositEntryListConverter.converterDate(depositEntries).orEmpty()) } val withdrawalMemoPossibility = asset.withdrawalMemoPossibility if (depositEntries == null) { diff --git a/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt b/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt index b6d1955446..70f641a62e 100644 --- a/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt +++ b/app/src/test/java/one/mixin/android/ReleaseKeepRulesTest.kt @@ -63,7 +63,7 @@ class ReleaseKeepRulesTest { companion object { private val appClasses by lazy { val names = setOf(TransferRequest::class.java, Base64::class.java) - .map { File(it.protectionDomain.codeSource.location.toURI()) } + .map { File(requireNotNull(it.protectionDomain?.codeSource?.location).toURI()) } .distinct() .flatMap { source -> if (source.isDirectory) { diff --git a/app/src/test/java/one/mixin/android/repository/TokenRepositoryWalletTransactionTest.kt b/app/src/test/java/one/mixin/android/repository/TokenRepositoryWalletTransactionTest.kt index 341c27be10..24b34c7c95 100644 --- a/app/src/test/java/one/mixin/android/repository/TokenRepositoryWalletTransactionTest.kt +++ b/app/src/test/java/one/mixin/android/repository/TokenRepositoryWalletTransactionTest.kt @@ -184,7 +184,7 @@ class TokenRepositoryWalletTransactionTest { assertEquals("pending", assertNotNull(raw("signed")).state) assertEquals("pending", assertNotNull(transaction("signed")).status) assertNull(raw("broadcast")) - withTimeout(5_000) { wallet.withRoomTransaction { Unit } } + withTimeout(5_000) { wallet.withRoomTransaction {} } } @Test @@ -241,7 +241,7 @@ class TokenRepositoryWalletTransactionTest { repository.insertRawTransactionAndUpdateTransactionStatus(rawRecord("bitcoin", bitcoin), "bitcoin", "failed", bitcoin, hex) assertEquals(output, wallet.walletOutputDao().outputByOutpoint(output.transactionHash, 0, bitcoin)) } - withTimeout(5_000) { wallet.withRoomTransaction { Unit } } + withTimeout(5_000) { wallet.withRoomTransaction {} } } @Test diff --git a/app/src/test/java/one/mixin/android/util/ErrorHandlerDatabaseErrorTest.kt b/app/src/test/java/one/mixin/android/util/ErrorHandlerDatabaseErrorTest.kt index 4d2d80968a..fefc282185 100644 --- a/app/src/test/java/one/mixin/android/util/ErrorHandlerDatabaseErrorTest.kt +++ b/app/src/test/java/one/mixin/android/util/ErrorHandlerDatabaseErrorTest.kt @@ -23,6 +23,7 @@ class ErrorHandlerDatabaseErrorTest { @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() + @Suppress("DEPRECATION") val resources = object : Resources(context.assets, context.resources.displayMetrics, context.resources.configuration) { override fun getString(id: Int): String = when (id) { R.string.Data_error -> "Data error" diff --git a/app/src/test/java/one/mixin/android/util/SerializationTest.kt b/app/src/test/java/one/mixin/android/util/SerializationTest.kt index fb9d13a691..90208f1e49 100644 --- a/app/src/test/java/one/mixin/android/util/SerializationTest.kt +++ b/app/src/test/java/one/mixin/android/util/SerializationTest.kt @@ -1,6 +1,5 @@ package one.mixin.android.util -import kotlinx.serialization.builtins.LongAsStringSerializer import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -48,7 +47,6 @@ class SerializationTest { @Test fun testTranscriptMessage() { - LongAsStringSerializer val content = """{"data":{"transcript_id":"464be61d-c837-4a7f-a102-3d5134ff4040","message_id":"fa928395-090a-4346-a854-d2bae96e0159","user_id":"cbb20923-9020-490a-b8f6-e816883c9c99","user_full_name":"Wu","category":"SIGNAL_TEXT","created_at":"2023-04-08T16:06:09.490Z","content":"android test","media_url":null,"media_name":null,"media_size":null,"media_width":null,"media_height":null,"media_mime_type":null,"media_duration":null,"media_status":"","media_waveform":null,"thumb_image":null,"thumb_url":null,"media_key":null,"media_digest":null,"media_created_at":null,"sticker_id":null,"shared_user_id":null,"mentions":null,"quote_id":null,"quote_content":null,"caption":null},"type":"transcript_message"}""" val transferData = json.decodeFromString>(content) diff --git a/app/src/test/java/one/mixin/android/util/URLDecoderTest.kt b/app/src/test/java/one/mixin/android/util/URLDecoderTest.kt index 38f6ec5e1a..719d65ec2f 100644 --- a/app/src/test/java/one/mixin/android/util/URLDecoderTest.kt +++ b/app/src/test/java/one/mixin/android/util/URLDecoderTest.kt @@ -18,18 +18,21 @@ class URLDecoderTest : TestCase() { "1. Incorrect encoding/decoding", URLDecoder.decode( URLEncoder.encode(URL, UTF_8.name()), + UTF_8.name(), ) == URL, ) assertTrue( "2. Incorrect encoding/decoding", URLDecoder.decode( URLEncoder.encode(URL2, UTF_8.name()), + UTF_8.name(), ) == URL2, ) assertTrue( "3. Incorrect encoding/decoding", URLDecoder.decode( URLEncoder.encode(URL3, UTF_8.name()), + UTF_8.name(), ) == URL3, ) } diff --git a/app/src/test/java/one/mixin/android/widget/linktext/AutoLinkTextViewTest.kt b/app/src/test/java/one/mixin/android/widget/linktext/AutoLinkTextViewTest.kt index 6086c0eef3..45e041343b 100644 --- a/app/src/test/java/one/mixin/android/widget/linktext/AutoLinkTextViewTest.kt +++ b/app/src/test/java/one/mixin/android/widget/linktext/AutoLinkTextViewTest.kt @@ -16,7 +16,7 @@ class AutoLinkTextViewTest { fun setUp() { val context = ApplicationProvider.getApplicationContext() runCatching { EmojiCompat.get() } - .getOrElse { EmojiCompat.init(BundledEmojiCompatConfig(context)) } + .getOrElse { EmojiCompat.init(BundledEmojiCompatConfig(context, Runnable::run)) } } @Test From eb9a17b2098e868bacbc183027f224c643aaf747 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 16 Sep 2026 17:09:44 +0800 Subject: [PATCH 08/29] fix(perps): align analytics events and sources --- .../link/LinkBottomSheetDialogFragment.kt | 5 +- .../ui/home/web3/trade/TradeFragment.kt | 8 +-- .../android/ui/home/web3/trade/TradePage.kt | 8 +-- .../home/web3/trade/perps/OpenPositionPage.kt | 2 +- .../home/web3/trade/perps/PerpetualContent.kt | 12 ++--- .../ui/home/web3/trade/perps/PerpsActivity.kt | 7 ++- .../PerpsAddBottomSheetDialogFragment.kt | 11 ++-- .../PerpsCloseBottomSheetDialogFragment.kt | 8 +-- .../PerpsConfirmBottomSheetDialogFragment.kt | 4 ++ .../PerpsMarginBottomSheetDialogFragment.kt | 15 ++++-- .../web3/trade/perps/PerpsMarketDetailPage.kt | 5 +- .../android/util/analytics/AnalyticsRules.kt | 7 +++ .../util/analytics/AnalyticsTracker.kt | 53 +++++++++++++++---- .../util/analytics/AnalyticsRulesTest.kt | 13 +++++ 14 files changed, 116 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt index 441aad95f1..9088ec5bac 100644 --- a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt @@ -1167,7 +1167,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { } else { val (previewMarket, preview) = perpsViewModel.prepareLinkPreview(market, openPosition, action.leaderPositionId, source) if (preview != PerpsLinkPreview.Input) { - showPerpsLinkPreview(previewMarket, openPosition, action.leaderPositionId, preview) + showPerpsLinkPreview(previewMarket, openPosition, action.leaderPositionId, preview, source) return } PerpsActivity.showOpenPosition( @@ -1225,6 +1225,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { action: PerpsOpenPositionAction, leaderPositionId: String?, preview: PerpsLinkPreview, + source: String, ) { lifecycle.withResumed { val manager = parentFragmentManager @@ -1261,6 +1262,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { leverage = action.leverage, margin = action.margin, error = getString(R.string.error_already_had_open_position), + source = source, position = preview.position, leaderPositionId = leaderPositionId, liquidationPrice = preview.liquidationPrice, @@ -1277,6 +1279,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { leverage = action.leverage, margin = action.margin, error = preview.message, + source = source, liquidationPrice = preview.liquidationPrice, ).showNow(manager, PerpsConfirmBottomSheetDialogFragment.FAILURE_TAG) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt index a44d0c5ccf..504d0b2b56 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt @@ -592,16 +592,16 @@ class TradeFragment : BaseFragment() { onShowAllMarkets = { initialCategory, initialSort -> PerpsMarketListBottomSheetDialogFragment.newInstance(initialCategory, initialSort).show(parentFragmentManager, PerpsMarketListBottomSheetDialogFragment.TAG) }, - onShowAllOpenPositions = { + onShowAllOpenPositions = { source -> requireActivity().supportFragmentManager.navigateToPerpsRoute( - AllPositionsFragment.newOpenInstance(), + AllPositionsFragment.newOpenInstance(source), AllPositionsFragment.TAG, R.id.container, ) }, - onShowAllClosedPositions = { + onShowAllClosedPositions = { source -> requireActivity().supportFragmentManager.navigateToPerpsRoute( - AllPositionsFragment.newClosedInstance(), + AllPositionsFragment.newClosedInstance(source), AllPositionsFragment.TAG, R.id.container, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt index b395dfb7ec..bcca2e7ee9 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt @@ -115,8 +115,8 @@ fun TradePage( onRecommendedMarketViewAllClick: (SwapRecommendedMarketType, Boolean) -> Unit, onShowMarketList: (Boolean) -> Unit, onShowAllMarkets: (String?, MarketSort?) -> Unit, - onShowAllOpenPositions: () -> Unit, - onShowAllClosedPositions: () -> Unit, + onShowAllOpenPositions: (String) -> Unit, + onShowAllClosedPositions: (String) -> Unit, onOpenPositionClick: (PerpsPositionItem) -> Unit, onMarketItemClick: (PerpsMarket) -> Unit, onClosedPositionClick: (PerpsOrderItem) -> Unit, @@ -313,7 +313,7 @@ fun TradePage( if (!isPerpetualOrderBadgeDismissed) { onDismissPerpetualOrderBadge() } - onShowAllOpenPositions() + onShowAllOpenPositions(AnalyticsTracker.PerpsSource.PERPS_HOME_MENU) } else { onOrderList(currentWalletId, false, spotTypeForPage(pagerState.currentPage)) } @@ -385,7 +385,7 @@ fun TradePage( { val source = when { perpetualTabIndex != null && pagerState.currentPage == perpetualTabIndex -> - AnalyticsTracker.CustomerServiceSource.TRADE_PERPS_HOME_MENU + AnalyticsTracker.CustomerServiceSource.PERPS_HOME_MENU pagerState.currentPage == 1 -> AnalyticsTracker.CustomerServiceSource.TRADE_ADVANCED_HOME_MENU else -> 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 23e7af213e..5e698a48d5 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 @@ -426,7 +426,6 @@ fun OpenPositionPage( token = currentToken?.toSwapToken(), text = usdtAmount, selectClick = { - AnalyticsTracker.trackPerpsOpenMarginSelect(currentToken?.chainName, currentToken?.symbol) availableTokens?.let(onTokenSelect) }, onInputChanged = { usdtAmount = it }, @@ -832,6 +831,7 @@ fun OpenPositionPage( leverage = leverage.toInt(), margin = normalizedAmount, error = message, + source = source, tokenSymbol = token.symbol, liquidationPrice = displayLiquidationPrice, ).showNow(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.FAILURE_TAG) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt index c032ad5845..1cc58c3f6e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt @@ -71,8 +71,8 @@ fun PerpetualContent( onShowTradingGuide: () -> Unit, onShowMarketList: (isLong: Boolean) -> Unit, onShowAllMarkets: (String?, MarketSort?) -> Unit, - onShowAllOpenPositions: () -> Unit, - onShowAllClosedPositions: () -> Unit, + onShowAllOpenPositions: (String) -> Unit, + onShowAllClosedPositions: (String) -> Unit, onOpenPositionClick: (PerpsPositionItem) -> Unit, onMarketItemClick: (PerpsMarket) -> Unit, onClosedPositionClick: (PerpsOrderItem) -> Unit, @@ -278,7 +278,7 @@ fun PerpetualContent( Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onShowAllOpenPositions) + .clickable { onShowAllOpenPositions(AnalyticsTracker.PerpsSource.PERPS_HOME_CARD_ARROW) } .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -315,7 +315,7 @@ fun PerpetualContent( if (openPositionsCount > openPositionsPreview.size) { Spacer(modifier = Modifier.height(12.dp)) - ViewAllAction(onClick = onShowAllOpenPositions) + ViewAllAction(onClick = { onShowAllOpenPositions(AnalyticsTracker.PerpsSource.PERPS_HOME_CARD_MORE) }) } } } @@ -463,7 +463,7 @@ fun PerpetualContent( Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onShowAllClosedPositions) + .clickable { onShowAllClosedPositions(AnalyticsTracker.PerpsSource.PERPS_HOME_CARD_ARROW) } .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -528,7 +528,7 @@ fun PerpetualContent( } if (closedPositions.size > closedPositionsPreview.size) { - ViewAllAction(onClick = onShowAllClosedPositions) + ViewAllAction(onClick = { onShowAllClosedPositions(AnalyticsTracker.PerpsSource.PERPS_HOME_CARD_MORE) }) } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt index 1257bf5100..0e9875cb42 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt @@ -221,7 +221,7 @@ class PerpsActivity : BaseActivity() { directOrderHandled = true currentIntent.putExtra(EXTRA_MODE, MODE_DETAIL) showMarketDetail(marketId, market.displaySymbol, market.displaySymbol, market.tokenSymbol, market, source) - showLeaderPositionFailure(market, openedPosition, isLong, initialLeverage, initialMargin) + showLeaderPositionFailure(market, openedPosition, isLong, initialLeverage, initialMargin, source) } else if (returnToDetail) { finish() } else { @@ -307,7 +307,7 @@ class PerpsActivity : BaseActivity() { position == null -> toast(R.string.error_not_found) position.state != PerpsPosition.STATE_OPEN -> toast(R.string.error_waiting_other_orders) supportFragmentManager.findFragmentByTag(PerpsMarginBottomSheetDialogFragment.TAG) == null -> { - PerpsMarginBottomSheetDialogFragment.newInstance(position, increase = true, initialMargin = initialMargin) + PerpsMarginBottomSheetDialogFragment.newInstance(position, increase = true, source = source, initialMargin = initialMargin) .showNow(supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) } } @@ -347,10 +347,12 @@ class PerpsActivity : BaseActivity() { isLong: Boolean, leverage: Int?, margin: String?, + source: String, ) { lifecycle.withResumed { PerpsConfirmBottomSheetDialogFragment.newFailureInstance( market, isLong, leverage, margin, getString(R.string.error_already_had_open_position), + source = source, position = position, leaderPositionId = leaderPositionId, ).showNow(supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.FAILURE_TAG) } @@ -363,6 +365,7 @@ class PerpsActivity : BaseActivity() { perpsTokens = tokens, ).setOnAssetClick { token -> selectedToken = token + AnalyticsTracker.trackPerpsOpenTokenSelect(token.chainName, token.symbol) }.setOnDepositClick { showDepositAssetSelection() }.show(supportFragmentManager, TokenListBottomSheetDialogFragment.TAG) 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 414279ccb0..ee97a6d47b 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 @@ -123,15 +123,18 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( const val TAG = "PerpsAddBottomSheetDialogFragment" private const val ARGS_POSITION = "args_position" private const val ARGS_INITIAL_MARGIN = "args_initial_margin" + private const val ARGS_SOURCE = "args_source" private const val ARGS_SHOW_LIQUIDATION_PRICE = "args_show_liquidation_price" fun newInstance( position: PerpsPositionItem, + source: String, showLiquidationPrice: Boolean = true, initialMargin: String? = null, ): PerpsAddBottomSheetDialogFragment { return PerpsAddBottomSheetDialogFragment().withArgs { putParcelable(ARGS_POSITION, position) + putString(ARGS_SOURCE, source) putString(ARGS_INITIAL_MARGIN, initialMargin) putBoolean(ARGS_SHOW_LIQUIDATION_PRICE, showLiquidationPrice) } @@ -220,6 +223,7 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( } LaunchedEffect(Unit) { + AnalyticsTracker.trackPerpsAddPositionStart(requireArguments().getString(ARGS_SOURCE).orEmpty()) viewModel.loadPerpsTokens( onSuccess = { tokens -> availableTokens = tokens @@ -244,7 +248,7 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( perpsTokens = tokens, ).setOnAssetClick { token -> selectedToken = token - AnalyticsTracker.trackPerpsAddPositionMarginSelect(token.chainName, token.symbol) + AnalyticsTracker.trackPerpsAddPositionTokenSelect(token.chainName, token.symbol) }.show(parentFragmentManager, TokenListBottomSheetDialogFragment.TAG) } }, @@ -965,13 +969,13 @@ internal fun FragmentActivity.showPerpsAddPosition( viewModel: PerpetualViewModel, position: PerpsPositionItem, market: PerpsMarket?, + source: String, initialMargin: String? = null, leaderPositionId: String? = null, onOrderCreated: () -> Unit = {}, onDismiss: () -> Unit = {}, ) { - AnalyticsTracker.trackPerpsAddPositionStart() - PerpsAddBottomSheetDialogFragment.newInstance(position, initialMargin = initialMargin) + PerpsAddBottomSheetDialogFragment.newInstance(position, source, initialMargin = initialMargin) .setOnDestroy(onDismiss) .setOnAdd { token, amount, liquidationPrice, latestMarket -> val currentMarket = latestMarket ?: market @@ -1015,6 +1019,7 @@ internal fun FragmentActivity.showPerpsAddPosition( leverage = position.leverage, margin = amount, error = message, + source = source, liquidationPrice = liquidationPrice, tokenSymbol = token.symbol, isAddPosition = true, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index fb1ea805c5..eea473866b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -524,11 +524,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(id = R.string.Retry), cancelAction = { - if (!isReduceMargin) AnalyticsTracker.trackPerpsClosePreviewCancel() + if (isReduceMargin) AnalyticsTracker.trackPerpsReduceMarginPreviewCancel() else AnalyticsTracker.trackPerpsClosePreviewCancel() dismiss() }, confirmAction = { - if (!isReduceMargin) AnalyticsTracker.trackPerpsClosePreviewConfirm() + if (isReduceMargin) AnalyticsTracker.trackPerpsReduceMarginPreviewConfirm() else AnalyticsTracker.trackPerpsClosePreviewConfirm() showVerifyPinThenClose() }, ) @@ -540,11 +540,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(id = R.string.Confirm), cancelAction = { - if (!isReduceMargin) AnalyticsTracker.trackPerpsClosePreviewCancel() + if (isReduceMargin) AnalyticsTracker.trackPerpsReduceMarginPreviewCancel() else AnalyticsTracker.trackPerpsClosePreviewCancel() dismiss() }, confirmAction = { - if (!isReduceMargin) AnalyticsTracker.trackPerpsClosePreviewConfirm() + if (isReduceMargin) AnalyticsTracker.trackPerpsReduceMarginPreviewConfirm() else AnalyticsTracker.trackPerpsClosePreviewConfirm() showVerifyPinThenClose() }, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt index 45280a3245..0565fed91e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt @@ -122,6 +122,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private const val ARGS_ORDER_ERROR = "args_order_error" private const val ARGS_MARKET_ID = "args_market_id" private const val ARGS_ADD_POSITION_ID = "args_add_position_id" + private const val ARGS_SOURCE = "args_source" const val RESULT_ORDER_CREATED = "perps_failure_order_created" const val RESULT_LEADER_POSITION_ID = "leader_position_id" @@ -131,6 +132,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm leverage: Int?, margin: String?, error: String, + source: String, tokenSymbol: String = market.quoteSymbol, isAddPosition: Boolean = false, position: PerpsPositionItem? = null, @@ -147,6 +149,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm putString(ARGS_TOKEN_SYMBOL, tokenSymbol) putBoolean(ARGS_IS_ADD_POSITION, isAddPosition) putString(ARGS_ORDER_ERROR, error) + putString(ARGS_SOURCE, source) putString(ARGS_MARKET_ID, market.marketId) putString(ARGS_LIQUIDATION_PRICE, liquidationPrice) putString(RESULT_LEADER_POSITION_ID, leaderPositionId) @@ -654,6 +657,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm position = requireNotNull(currentPosition), market = market, initialMargin = amount.takeIf { it.isNotBlank() }, + source = requireArguments().getString(ARGS_SOURCE).orEmpty(), leaderPositionId = leaderId, onOrderCreated = { manager.setFragmentResult(RESULT_ORDER_CREATED, Bundle().apply { putString(RESULT_LEADER_POSITION_ID, leaderId) }) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 943563384b..ec422f99e2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -110,12 +110,14 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme private const val ARGS_POSITION = "args_position" private const val ARGS_INCREASE = "args_increase" private const val ARGS_INITIAL_MARGIN = "args_initial_margin" + private const val ARGS_SOURCE = "args_source" private const val PREF_REDUCE_BY_PERCENT = "perps_reduce_margin_by_percent" - fun newInstance(position: PerpsPositionItem, increase: Boolean, initialMargin: String? = null) = PerpsMarginBottomSheetDialogFragment().withArgs { + fun newInstance(position: PerpsPositionItem, increase: Boolean, source: String, initialMargin: String? = null) = PerpsMarginBottomSheetDialogFragment().withArgs { putParcelable(ARGS_POSITION, position) putBoolean(ARGS_INCREASE, increase) putString(ARGS_INITIAL_MARGIN, initialMargin) + putString(ARGS_SOURCE, source) } } @@ -230,8 +232,9 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } } LaunchedEffect(increase) { + val source = requireArguments().getString(ARGS_SOURCE).orEmpty() if (increase) { - AnalyticsTracker.trackPerpsAddMarginStart() + AnalyticsTracker.trackPerpsAddMarginStart(source) viewModel.loadAcceptedAssets( onSuccess = { ids -> acceptedAssets = ids @@ -242,6 +245,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme }, onError = { error = it }, ) + } else { + AnalyticsTracker.trackPerpsReduceMarginStart(source) } } @@ -286,6 +291,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme isAddMargin = true, ).show(parentFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) } else { + AnalyticsTracker.trackPerpsReduceMarginEnd() toast(R.string.perps_margin_submitted) viewModel.refreshSinglePosition(currentPosition.positionId, currentPosition.walletId) viewModel.refreshOrders(currentPosition.walletId) @@ -325,6 +331,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme keyboardController?.hide() focusManager.clearFocus() val approvedAmount = normalizedAmount + AnalyticsTracker.trackPerpsReduceMarginPreview() PerpsCloseBottomSheetDialogFragment.newInstance(currentPosition.toPosition(), reduceMarginAmount = approvedAmount) .setOnMarginConfirmed { submitCurrent(approvedAmount) } .show(parentFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) @@ -342,7 +349,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme canSubmit = canSubmit, loading = loading, onCancel = { - if (increase) AnalyticsTracker.trackPerpsAddMarginCancel() + if (increase) AnalyticsTracker.trackPerpsAddMarginCancel() else AnalyticsTracker.trackPerpsReduceMarginCancel() dismiss() }, onSubmit = onSubmit, @@ -383,7 +390,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme ).setOnAssetClick { token -> if (token.assetId in acceptedAssets) { selectedToken = token - AnalyticsTracker.trackPerpsAddMarginMarginSelect(token.chainName, token.symbol) + AnalyticsTracker.trackPerpsAddMarginTokenSelect(token.chainName, token.symbol) } }.show(parentFragmentManager, TokenListBottomSheetDialogFragment.TAG) }) else null, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 8e377a1494..db4cfe0ac4 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -585,11 +585,11 @@ fun PerpsMarketDetailPage( val positionForAdd = currentPosition PerpsAdjustBottomSheetDialogFragment().apply { onAddMargin = { - PerpsMarginBottomSheetDialogFragment.newInstance(positionForAdd, increase = true) + PerpsMarginBottomSheetDialogFragment.newInstance(positionForAdd, increase = true, source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU) .show(activity.supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) } onReduceMargin = { - PerpsMarginBottomSheetDialogFragment.newInstance(positionForAdd, increase = false) + PerpsMarginBottomSheetDialogFragment.newInstance(positionForAdd, increase = false, source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU) .show(activity.supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) } onAddPosition = { @@ -598,6 +598,7 @@ fun PerpsMarketDetailPage( viewModel = viewModel, position = positionForAdd, market = market, + source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_ADD, onDismiss = { isAddingProcessing = false }, ) } diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsRules.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsRules.kt index 79685801ac..b57fd72855 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsRules.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsRules.kt @@ -83,6 +83,13 @@ internal object AnalyticsRules { fun spotOrderDetailEvent(type: String) = AnalyticsEvent("trade_spot_order_detail", mapOf("type" to type)) + fun perpsAdjustmentSource(source: String, defaultSource: String): String = + when (source) { + AnalyticsTracker.PerpsSource.APP_CARD -> AnalyticsTracker.PerpsSource.APP_CARD + AnalyticsTracker.MarketDetailSource.SCHEMA, AnalyticsTracker.TradeSource.SCHEMA, AnalyticsTracker.PerpsSource.URL -> AnalyticsTracker.PerpsSource.URL + else -> defaultSource + } + fun assetVisibilityEvent( hidden: Boolean, wallet: String, diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt index 70e3976923..9f4423fa2d 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt @@ -501,7 +501,7 @@ object AnalyticsTracker { const val PERPS_MARKET_DETAIL = "perps_market_detail" const val PERPS_ALL_POSITIONS = "perps_all_positions" const val PERPS_ACTIVITY_DETAIL = "perps_activity_detail" - const val TRADE_PERPS_HOME_MENU = "trade_perps_home_menu" + const val PERPS_HOME_MENU = "perps_home_menu" const val TRADE_SIMPLE_HOME_MENU = "trade_simple_home_menu" const val TRADE_ADVANCED_HOME_MENU = "trade_advanced_home_menu" const val ADD_PHONE_NOTICE = "add_phone_notice" @@ -631,10 +631,15 @@ object AnalyticsTracker { const val FIRST_GUIDE = SpotGuideSource.FIRST_GUIDE const val PERPS_HOME_MENU = "perps_home_menu" const val PERPS_HOME_CARD = "perps_home_card" + const val PERPS_HOME_CARD_MORE = "perps_home_card_more" + const val PERPS_HOME_CARD_ARROW = "perps_home_card_arrow" const val PERPS_DETAIL_CARD = "perps_detail_card" const val PERPS_OPEN_POSITION_SIZE = "perps_open_position_size" const val SPOT_MARKET_DETAIL = "spot_market_detail" const val PERPS_MARKET_DETAIL = "perps_market_detail" + const val PERPS_MARKET_DETAIL_ADD = "perps_market_detail_add" + const val PERPS_MARKET_DETAIL_BOTTOM_MENU = "perps_market_detail_bottom_menu" + const val URL = "url" const val PERPS_ALL_POSITIONS = "perps_all_positions" const val PERPS_ACTIVITY_DETAIL = "perps_activity_detail" const val PERPS_HOME_LIST = "perps_home_list" @@ -707,8 +712,8 @@ object AnalyticsTracker { } } - fun trackPerpsOpenMarginSelect(chain: String?, assetSymbol: String?) { - logEvent("trade_perps_open_margin_select") { + fun trackPerpsOpenTokenSelect(chain: String?, assetSymbol: String?) { + logEvent("trade_perps_open_token_select") { putString("chain", chain) putString("asset_symbol", assetSymbol) } @@ -749,14 +754,14 @@ object AnalyticsTracker { } } - fun trackPerpsAddPositionStart() { + fun trackPerpsAddPositionStart(source: String) { logEvent("trade_perps_add_position_start") { - putString("type", "add_position") + putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_ADD)) } } - fun trackPerpsAddPositionMarginSelect(chain: String?, assetSymbol: String?) { - logEvent("trade_perps_add_position_margin_select") { + fun trackPerpsAddPositionTokenSelect(chain: String?, assetSymbol: String?) { + logEvent("trade_perps_add_position_token_select") { putString("chain", chain) putString("asset_symbol", assetSymbol) } @@ -782,14 +787,14 @@ object AnalyticsTracker { logEvent("trade_perps_add_position_cancel") } - fun trackPerpsAddMarginStart() { + fun trackPerpsAddMarginStart(source: String) { logEvent("trade_perps_add_margin_start") { - putString("type", "add_margin") + putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU)) } } - fun trackPerpsAddMarginMarginSelect(chain: String?, assetSymbol: String?) { - logEvent("trade_perps_add_margin_margin_select") { + fun trackPerpsAddMarginTokenSelect(chain: String?, assetSymbol: String?) { + logEvent("trade_perps_add_margin_token_select") { putString("chain", chain) putString("asset_symbol", assetSymbol) } @@ -815,6 +820,32 @@ object AnalyticsTracker { logEvent("trade_perps_add_margin_cancel") } + fun trackPerpsReduceMarginStart(source: String) { + logEvent("trade_perps_reduce_margin_start") { + putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU)) + } + } + + fun trackPerpsReduceMarginPreview() { + logEvent("trade_perps_reduce_margin_preview") + } + + fun trackPerpsReduceMarginPreviewConfirm() { + logEvent("trade_perps_reduce_margin_preview_confirm") + } + + fun trackPerpsReduceMarginPreviewCancel() { + logEvent("trade_perps_reduce_margin_preview_cancel") + } + + fun trackPerpsReduceMarginCancel() { + logEvent("trade_perps_reduce_margin_cancel") + } + + fun trackPerpsReduceMarginEnd() { + logEvent("trade_perps_reduce_margin_end") + } + fun trackPerpsCloseStart(type: String) { logEvent("trade_perps_close_start") { putString("type", type) diff --git a/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt b/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt index 0304780678..a39485c235 100644 --- a/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt +++ b/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt @@ -5,6 +5,19 @@ import kotlin.test.assertNull import org.junit.Test class AnalyticsRulesTest { + @Test + fun perpsAdjustmentsPreserveLinkSourcesAndUseTheActionEntryForOtherSources() { + for (entry in listOf("perps_market_detail_add", "perps_market_detail_bottom_menu")) { + assertEquals("app_card", AnalyticsRules.perpsAdjustmentSource("app_card", entry)) + assertEquals("url", AnalyticsRules.perpsAdjustmentSource("schema", entry)) + assertEquals("url", AnalyticsRules.perpsAdjustmentSource("scheme", entry)) + assertEquals("url", AnalyticsRules.perpsAdjustmentSource("url", entry)) + assertEquals(entry, AnalyticsRules.perpsAdjustmentSource(entry, entry)) + assertEquals(entry, AnalyticsRules.perpsAdjustmentSource("perps_market_detail", entry)) + assertEquals(entry, AnalyticsRules.perpsAdjustmentSource("", entry)) + } + } + @Test fun marketShareEventUsesMarketShareNameAndParams() { val event = From f74b31b9449ec8e691fb68a8be61717aeb50173d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 16 Sep 2026 17:46:50 +0800 Subject: [PATCH 09/29] fix(l10n): sync perps margin strings --- app/src/main/res/values-es/strings.xml | 7 +++++-- app/src/main/res/values-in/strings.xml | 7 +++++-- app/src/main/res/values-ja/strings.xml | 9 ++++++--- app/src/main/res/values-ko/strings.xml | 9 ++++++--- app/src/main/res/values-ms/strings.xml | 7 +++++-- app/src/main/res/values-ru/strings.xml | 9 ++++++--- app/src/main/res/values-zh-rCN/strings.xml | 9 ++++++--- app/src/main/res/values-zh-rTW/strings.xml | 19 +++++++++++-------- app/src/main/res/values/strings.xml | 5 ++++- 9 files changed, 54 insertions(+), 27 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d5b5c3ceac..4d9955083c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1197,12 +1197,15 @@ Margen disponible para retirar: %1$s Ajuste de margen enviado Confirmar adición de margen - ¿Reducir el margen en %1$s? Esto aumenta el riesgo de liquidación. + Confirmar reducción del margen Añadir margen a %1$s %2$s - Reducir margen de %1$s %2$s + Reducir margen para %1$s %2$s Reducir Porcentaje Confirmar reducción de margen Precio de marca (depuración) Hasta %1$s APY + Añadir posición a %1$s %2$s + Reducir posición para %1$s %2$s + Máximo reducible %1$s diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 2e4fbf8d64..ebba248d6f 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -755,12 +755,15 @@ Margin yang dapat ditarik: %1$s Penyesuaian margin telah diajukan Konfirmasi Penambahan Margin - Kurangi margin sebesar %1$s? Ini meningkatkan risiko likuidasi. - Tambah Margin untuk %1$s %2$s + Konfirmasi Pengurangan Margin + Tambah Margin ke %1$s %2$s Kurangi Margin untuk %1$s %2$s Kurangi Persentase Konfirmasi Pengurangan Margin Harga Mark (Debug) Hingga %1$s APY + Tambah Posisi ke %1$s %2$s + Kurangi Posisi untuk %1$s %2$s + Maksimal yang dapat dikurangi %1$s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 3c365eec07..13032ea245 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1143,12 +1143,15 @@ 引き出し可能な証拠金:%1$s 証拠金の調整を送信しました 証拠金の追加を確認 - 証拠金を %1$s 減らしますか?強制決済リスクが高まります。 - %1$s %2$sの証拠金を追加 - %1$s %2$sの証拠金を減らす + 証拠金の減少を確認 + %1$s %2$s に証拠金を追加 + %1$s %2$s の証拠金を減らす 減らす 割合 証拠金の減額を確認 マーク価格(デバッグ) 最大 %1$s APY + %1$s %2$s にポジションを追加 + %1$s %2$s のポジションを減らす + 最大減少量 %1$s diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e0f4fcf477..34d2281995 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2679,11 +2679,14 @@ 인출 가능한 증거금: %1$s 증거금 조정이 제출되었습니다 증거금 추가 확인 - 증거금을 %1$s 줄일까요? 강제 청산 위험이 증가합니다. - %1$s %2$s 증거금 추가 - %1$s %2$s 증거금 감소 + 증거금 감소 확인 + %1$s %2$s에 증거금 추가 + %1$s %2$s의 증거금 감소 감소 백분율 증거금 감소 확인 최대 %1$s APY + %1$s %2$s에 포지션 추가 + %1$s %2$s의 포지션 축소 + 최대 축소 가능 %1$s diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 51a7e511b1..e9b80cb183 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -820,12 +820,15 @@ Margin yang boleh dikeluarkan: %1$s Pelarasan margin dihantar Sahkan Penambahan Margin - Kurangkan margin sebanyak %1$s? Ini meningkatkan risiko likuidasi. - Tambah Margin untuk %1$s %2$s + Sahkan Pengurangan Margin + Tambah Margin pada %1$s %2$s Kurangkan Margin untuk %1$s %2$s Kurangkan Peratusan Sahkan Pengurangan Margin Harga Mark (Nyahpepijat) Sehingga %1$s APY + Tambah Posisi pada %1$s %2$s + Kurangkan Posisi untuk %1$s %2$s + Maksimum boleh dikurangkan %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 51a90f4e40..ed5da30bef 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1138,12 +1138,15 @@ Маржа, доступная для вывода: %1$s Запрос на изменение маржи отправлен Подтвердить добавление маржи - Уменьшить маржу на %1$s? Это повысит риск ликвидации. - Добавить маржу к %1$s %2$s - Уменьшить маржу %1$s %2$s + Подтвердить уменьшение маржи + Добавить маржу в %1$s %2$s + Уменьшить маржу для %1$s %2$s Уменьшить Процент Подтвердить уменьшение маржи Цена маркировки (отладка) До %1$s APY + Увеличить позицию в %1$s %2$s + Уменьшить позицию для %1$s %2$s + Максимально можно уменьшить на %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 757d498238..1c607873bc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2628,11 +2628,14 @@ 可减少保证金:%1$s 保证金调整已提交 确认添加保证金 - 减少 %1$s 保证金?这会增加强平风险。 - 为%1$s %2$s添加保证金 - 减少%1$s %2$s的保证金 + 确认减少保证金 + %1$s %2$s 添加保证金 + %1$s %2$s 减少保证金 减少 百分比 确认减少保证金 最高年化 %1$s + %1$s %2$s 增加仓位 + %1$s %2$s 减少仓位 + 最多减少 %1$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2113b8bffb..bd5a2a716a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2664,7 +2664,7 @@ 收益率 調整 調整保證金或倉位 - 添加保證金 + 新增保證金 減少保證金 增加倉位 減少倉位 @@ -2672,19 +2672,22 @@ 減少多餘保證金,在保持持倉的同時釋放資金 增加倉位,放大潛在收益與虧損 部分平倉,鎖定收益或減少虧損 - 添加保證金還是增加倉位? - 添加保證金可降低強平風險 + 新增保證金還是增加倉位? + 新增保證金可降低強平風險 加倉會放大收益和風險 - 保證金不足時,優先添加保證金 + 保證金不足時,優先新增保證金 總保證金 可減少保證金:%1$s 保證金調整已提交 - 確認添加保證金 - 減少 %1$s 保證金?這會增加強平風險。 - 為%1$s %2$s添加保證金 - 減少%1$s %2$s的保證金 + 確認新增保證金 + 確認減少保證金 + %1$s %2$s 新增保證金 + %1$s %2$s 減少保證金 減少 百分比 確認減少保證金 最高年化 %1$s + %1$s %2$s 增加倉位 + %1$s %2$s 減少倉位 + 最多減少 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d33dcc647..a4d8856957 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2709,11 +2709,14 @@ Available margin: %1$s Margin adjustment submitted Confirm Adding Margin - Reduce margin by %1$s? This increases liquidation risk. + Confirm Reducing Margin Add Margin to %1$s %2$s Reduce Margin for %1$s %2$s Reduce Percentage Confirm Reducing Margin Up to %1$s APY + Add Position to %1$s %2$s + Reduce Position for %1$s %2$s + Max removable %1$s From 8189d5ba97b2e317910813320828fb6598344eee Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 16 Sep 2026 18:15:36 +0800 Subject: [PATCH 10/29] fix(perps): fill margin amount from balance --- .../web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index ec422f99e2..8274358924 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -414,6 +414,9 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme text = selectedToken?.balance?.numberFormat8() ?: "0", color = MixinAppTheme.colors.textAssist, fontSize = 12.sp, + modifier = Modifier.clickable(enabled = !loading && balanceUsd != null) { + balanceUsd?.let { changeAmount(formatMarginAdjustmentInput(it, false)) } + }, ) Spacer(Modifier.width(8.dp)) Text( From 072b8c22250d9e56458040cca58bc14fd3bcc543 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 17 Sep 2026 11:46:55 +0800 Subject: [PATCH 11/29] fix(perps): restore margin actions and input state --- .../PerpsAdjustBottomSheetDialogFragment.kt | 41 ++++--- .../PerpsCloseBottomSheetDialogFragment.kt | 42 +++++--- .../PerpsMarginBottomSheetDialogFragment.kt | 46 ++++++-- .../web3/trade/perps/PerpsMarketDetailPage.kt | 60 +++++++---- .../web3/trade/perps/PerpsDialogResultTest.kt | 100 ++++++++++++++++++ .../web3/trade/perps/PerpsMarginInputTest.kt | 18 ++++ 6 files changed, 245 insertions(+), 62 deletions(-) create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsDialogResultTest.kt create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginInputTest.kt diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt index e424763a11..dc7fa28d97 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3.trade.perps +import android.os.Bundle import android.view.View import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -36,17 +37,26 @@ import one.mixin.android.R import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.withArgs import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.ui.wallet.alert.components.cardBackground @AndroidEntryPoint class PerpsAdjustBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { companion object { const val TAG = "PerpsAdjustBottomSheetDialogFragment" - } + const val RESULT_ACTION = "action" + const val ACTION_ADD_MARGIN = "add_margin" + const val ACTION_REDUCE_MARGIN = "reduce_margin" + const val ACTION_ADD_POSITION = "add_position" + private const val ARGS_POSITION_ID = "args_position_id" + + fun newInstance(positionId: String) = PerpsAdjustBottomSheetDialogFragment().withArgs { + putString(ARGS_POSITION_ID, positionId) + } - var onAddMargin: (() -> Unit)? = null - var onReduceMargin: (() -> Unit)? = null - var onAddPosition: (() -> Unit)? = null + fun resultKey(positionId: String) = "$TAG:$positionId" + } override fun getTheme() = R.style.AppTheme_Dialog @@ -58,22 +68,19 @@ class PerpsAdjustBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme MixinAppTheme { PerpsAdjustContent( onDismiss = { dismiss() }, - onAddMargin = { - dismiss() - onAddMargin?.invoke() - }, - onReduceMargin = { - dismiss() - onReduceMargin?.invoke() - }, - onAddPosition = { - dismiss() - onAddPosition?.invoke() - }, + onAddMargin = { selectAction(ACTION_ADD_MARGIN) }, + onReduceMargin = { selectAction(ACTION_REDUCE_MARGIN) }, + onAddPosition = { selectAction(ACTION_ADD_POSITION) }, ) } } + private fun selectAction(action: String) { + val key = resultKey(requireNotNull(requireArguments().getString(ARGS_POSITION_ID))) + parentFragmentManager.setFragmentResult(key, Bundle().apply { putString(RESULT_ACTION, action) }) + dismiss() + } + override fun showError(error: String) = Unit } @@ -168,7 +175,7 @@ private fun AdjustAction( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .background(MixinAppTheme.colors.background) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) .clickable(onClick = onClick) .padding(start = 20.dp, end = 16.dp, top = 13.dp, bottom = 13.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index eea473866b..e1152d5453 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -3,6 +3,7 @@ package one.mixin.android.ui.home.web3.trade.perps import android.annotation.SuppressLint import android.app.Dialog import android.content.DialogInterface +import android.os.Bundle import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -82,6 +83,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen companion object { const val TAG = "PerpsCloseBottomSheetDialogFragment" + const val RESULT_MARGIN_CONFIRMED = "perps_margin_confirmed" + const val RESULT_POSITION_ID = "position_id" + const val RESULT_AMOUNT = "amount" private const val ARGS_POSITION_ID = "args_position_id" private const val ARGS_REDUCE_MARGIN_AMOUNT = "args_reduce_margin_amount" private const val ARGS_SIDE = "args_side" @@ -128,6 +132,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen override fun onStart() { super.onStart() + (childFragmentManager.findFragmentByTag(VerifyBottomSheetDialogFragment.TAG) as? VerifyBottomSheetDialogFragment) + ?.let(::bindPinVerification) dialog?.window?.let { window -> SystemUIManager.lightUI( window, @@ -573,32 +579,38 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen super.onDismiss(dialog) } - private var onMarginConfirmed: (() -> Unit)? = null - - fun setOnMarginConfirmed(callback: () -> Unit): PerpsCloseBottomSheetDialogFragment { - onMarginConfirmed = callback - return this - } - private fun showVerifyPinThenClose() { - if (parentFragmentManager.findFragmentByTag(VerifyBottomSheetDialogFragment.TAG) != null) return - if (isReduceMargin && (marginAdjustmentAmount(reduceMarginAmount.orEmpty()) == null || onMarginConfirmed == null)) { + if (childFragmentManager.findFragmentByTag(VerifyBottomSheetDialogFragment.TAG) != null) return + if (isReduceMargin && marginAdjustmentAmount(reduceMarginAmount.orEmpty()) == null) { errorInfo = getString(R.string.Data_error) step = Step.Error return } VerifyBottomSheetDialogFragment.newInstance( title = getString(R.string.Verify_PIN), - ).apply { - disableToast = true - }.setOnPinSuccess { + ).also(::bindPinVerification).showNow(childFragmentManager, VerifyBottomSheetDialogFragment.TAG) + } + + private fun bindPinVerification(verification: VerifyBottomSheetDialogFragment) { + verification.disableToast = true + verification.setOnPinSuccess { if (isReduceMargin) { - onMarginConfirmed?.invoke() - dismiss() + confirmMargin() } else { closePosition() } - }.showNow(parentFragmentManager, VerifyBottomSheetDialogFragment.TAG) + } + } + + private fun confirmMargin() { + parentFragmentManager.setFragmentResult( + RESULT_MARGIN_CONFIRMED, + Bundle().apply { + putString(RESULT_POSITION_ID, positionId) + putString(RESULT_AMOUNT, requireNotNull(reduceMarginAmount)) + }, + ) + dismiss() } private fun closePosition() { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 8274358924..24302e1541 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -30,6 +30,7 @@ import androidx.compose.material.Slider import androidx.compose.material.SliderDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,10 +54,12 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -316,6 +319,17 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } val submitCurrent by rememberUpdatedState(::submit) + if (!increase) { + DisposableEffect(lifecycleOwner) { + val manager = childFragmentManager + manager.setFragmentResultListener(PerpsCloseBottomSheetDialogFragment.RESULT_MARGIN_CONFIRMED, lifecycleOwner) { _, result -> + if (result.getString(PerpsCloseBottomSheetDialogFragment.RESULT_POSITION_ID) == initialPosition.positionId) { + result.getString(PerpsCloseBottomSheetDialogFragment.RESULT_AMOUNT)?.let { submitCurrent(it) } + } + } + onDispose { manager.clearFragmentResultListener(PerpsCloseBottomSheetDialogFragment.RESULT_MARGIN_CONFIRMED) } + } + } MixinAppTheme { val reductionLimitError = if (exceedsReductionLimit && maximumReduction != null) { val limit = if (reduceByPercent) { @@ -327,14 +341,13 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val onSubmit = { if (increase) { submit(normalizedAmount) - } else if (canSubmit && parentFragmentManager.findFragmentByTag(PerpsCloseBottomSheetDialogFragment.TAG) == null) { + } else if (canSubmit && childFragmentManager.findFragmentByTag(PerpsCloseBottomSheetDialogFragment.TAG) == null) { keyboardController?.hide() focusManager.clearFocus() val approvedAmount = normalizedAmount AnalyticsTracker.trackPerpsReduceMarginPreview() PerpsCloseBottomSheetDialogFragment.newInstance(currentPosition.toPosition(), reduceMarginAmount = approvedAmount) - .setOnMarginConfirmed { submitCurrent(approvedAmount) } - .show(parentFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + .show(childFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) } } PerpsMarginContent( @@ -630,6 +643,14 @@ private fun PerpsReduceMarginInput( onInputChanged: (String) -> Unit, onToggleMode: () -> Unit, ) { + var textFieldValue by remember(isPercentage) { mutableStateOf(TextFieldValue(input, TextRange(input.length))) } + LaunchedEffect(input) { + textFieldValue = syncPerpsMarginInput(textFieldValue, input) + } + fun updateInput(value: String) { + textFieldValue = TextFieldValue(value, TextRange(value.length)) + onInputChanged(value) + } val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current val inputValue = input.toBigDecimalOrNull() ?: BigDecimal.ZERO @@ -654,7 +675,7 @@ private fun PerpsReduceMarginInput( } fun selectPercentage(value: BigDecimal) { val selected = if (isPercentage) value else reduceMarginAmount(margin?.toPlainString(), value.toPlainString(), true) ?: BigDecimal.ZERO - onInputChanged(formatMarginAdjustmentInput(selected, isPercentage)) + updateInput(formatMarginAdjustmentInput(selected, isPercentage)) } Column( modifier = Modifier @@ -666,7 +687,7 @@ private fun PerpsReduceMarginInput( ) { Row(verticalAlignment = Alignment.CenterVertically) { IconButton( - onClick = { onInputChanged(formatMarginAdjustmentInput((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO), isPercentage)) }, + onClick = { updateInput(formatMarginAdjustmentInput((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO), isPercentage)) }, enabled = enabled && inputValue > BigDecimal.ZERO, ) { Icon(painterResource(R.drawable.ic_perps_minus), stringResource(R.string.perps_reduce_action), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) @@ -674,10 +695,14 @@ private fun PerpsReduceMarginInput( Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { if (!isPercentage) Text(PERPS_USD_SYMBOL, fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) BasicTextField( - value = input, + value = textFieldValue, onValueChange = { value -> - if (value.length <= 40 && value.all { it in '0'..'9' || (!isPercentage && it == '.') } && value.count { it == '.' } <= 1 && - value.substringAfter('.', "").length <= 2) onInputChanged(value) + val text = value.text + if (text.length <= 40 && text.all { it in '0'..'9' || (!isPercentage && it == '.') } && text.count { it == '.' } <= 1 && + text.substringAfter('.', "").length <= 2) { + textFieldValue = value + if (text != input) onInputChanged(text) + } }, enabled = enabled, singleLine = true, @@ -695,7 +720,7 @@ private fun PerpsReduceMarginInput( if (isPercentage) Text("%", fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) } IconButton( - onClick = { onInputChanged(formatMarginAdjustmentInput((inputValue + BigDecimal.ONE).min(maximum), isPercentage)) }, + onClick = { updateInput(formatMarginAdjustmentInput((inputValue + BigDecimal.ONE).min(maximum), isPercentage)) }, enabled = enabled && inputValue < maximum, ) { Icon(painterResource(R.drawable.ic_perps_add), stringResource(R.string.Add), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) @@ -764,6 +789,9 @@ private fun PerpsReduceMarginInput( } } +internal fun syncPerpsMarginInput(value: TextFieldValue, input: String): TextFieldValue = + if (value.text == input) value else TextFieldValue(input, TextRange(input.length)) + @Preview(name = "100%", widthDp = 360, heightDp = 560, locale = "zh") @Composable private fun PerpsMarginPercentagePreview() { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index db4cfe0ac4..f55e4312e5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -29,11 +29,13 @@ import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -145,6 +147,41 @@ fun PerpsMarketDetailPage( var previousOpenPositionsCount by remember(walletId) { mutableStateOf(null) } var isAddingProcessing by remember { mutableStateOf(false) } val currentPosition = openPositions?.firstOrNull { it.marketId == marketId } + val activity = context as? FragmentActivity + val onAdjustment by rememberUpdatedState { action: String -> + val position = currentPosition + if (activity != null && position?.state == PerpsPosition.STATE_OPEN && !isAddingProcessing) { + when (action) { + PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_MARGIN, + PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_MARGIN -> { + PerpsMarginBottomSheetDialogFragment.newInstance( + position, + increase = action == PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_MARGIN, + source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU, + ).show(activity.supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) + } + PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_POSITION -> { + isAddingProcessing = true + activity.showPerpsAddPosition( + viewModel = viewModel, + position = position, + market = market, + source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_ADD, + onDismiss = { isAddingProcessing = false }, + ) + } + } + } + } + val adjustmentResultKey = currentPosition?.positionId?.let { PerpsAdjustBottomSheetDialogFragment.resultKey(it) } + DisposableEffect(activity, lifecycleOwner, adjustmentResultKey) { + if (activity == null || adjustmentResultKey == null) return@DisposableEffect onDispose {} + val manager = activity.supportFragmentManager + manager.setFragmentResultListener(adjustmentResultKey, lifecycleOwner) { _, result -> + onAdjustment(result.getString(PerpsAdjustBottomSheetDialogFragment.RESULT_ACTION).orEmpty()) + } + onDispose { manager.clearFragmentResultListener(adjustmentResultKey) } + } val hasLoadedOpenPositions = openPositions != null val closedPositions = allClosedPositions.filter { it.marketId == marketId } val timeFrameValues = listOf("1m", "5m", "15m", "1h", "4h", "1d", "1w") @@ -582,27 +619,8 @@ fun PerpsMarketDetailPage( if (isAddingProcessing) return@MixinButton val activity = context as? FragmentActivity ?: return@MixinButton if (activity.supportFragmentManager.findFragmentByTag(PerpsAdjustBottomSheetDialogFragment.TAG) != null) return@MixinButton - val positionForAdd = currentPosition - PerpsAdjustBottomSheetDialogFragment().apply { - onAddMargin = { - PerpsMarginBottomSheetDialogFragment.newInstance(positionForAdd, increase = true, source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU) - .show(activity.supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) - } - onReduceMargin = { - PerpsMarginBottomSheetDialogFragment.newInstance(positionForAdd, increase = false, source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU) - .show(activity.supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) - } - onAddPosition = { - isAddingProcessing = true - activity.showPerpsAddPosition( - viewModel = viewModel, - position = positionForAdd, - market = market, - source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_ADD, - onDismiss = { isAddingProcessing = false }, - ) - } - }.show(activity.supportFragmentManager, PerpsAdjustBottomSheetDialogFragment.TAG) + PerpsAdjustBottomSheetDialogFragment.newInstance(currentPosition.positionId) + .show(activity.supportFragmentManager, PerpsAdjustBottomSheetDialogFragment.TAG) }, backgroundColor = MixinAppTheme.colors.walletGreen, contentColor = Color.White, diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsDialogResultTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsDialogResultTest.kt new file mode 100644 index 0000000000..a9f58b7278 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsDialogResultTest.kt @@ -0,0 +1,100 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.app.Application +import android.os.Bundle +import android.os.Parcel +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentFactory +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.response.perps.toPosition +import one.mixin.android.ui.common.VerifyBottomSheetDialogFragment +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter +import kotlin.test.assertEquals +import kotlin.test.fail + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, manifest = Config.NONE, sdk = [29]) +class PerpsDialogResultTest { + @Test + fun restoredMenuReturnsEachActionToItsPositionOnce() { + for (action in listOf( + PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_MARGIN, + PerpsAdjustBottomSheetDialogFragment.ACTION_REDUCE_MARGIN, + PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_POSITION, + )) { + assertRestoredResult( + original = PerpsAdjustBottomSheetDialogFragment.newInstance("position"), + requestKey = PerpsAdjustBottomSheetDialogFragment.resultKey("position"), + send = { fragment -> + ReflectionHelpers.callInstanceMethod(fragment, "selectAction", ClassParameter.from(String::class.java, action)) + }, + ) { result -> + assertEquals(action, result.getString(PerpsAdjustBottomSheetDialogFragment.RESULT_ACTION)) + } + } + } + + @Test + fun restoredReductionReturnsTheApprovedAmountOnce() { + val position = PerpsPositionItem("position", "market", "long", "1", "100", 10, margin = "10") + assertRestoredResult( + original = PerpsCloseBottomSheetDialogFragment.newInstance(position.toPosition(), reduceMarginAmount = "3.125"), + requestKey = PerpsCloseBottomSheetDialogFragment.RESULT_MARGIN_CONFIRMED, + send = { fragment -> + val verification = restoreArguments(VerifyBottomSheetDialogFragment.newInstance()) as VerifyBottomSheetDialogFragment + ReflectionHelpers.callInstanceMethod(fragment, "bindPinVerification", ClassParameter.from(VerifyBottomSheetDialogFragment::class.java, verification)) + ReflectionHelpers.getField<(String) -> Unit>(verification, "onPinSuccess").invoke("") + }, + ) { result -> + assertEquals("position", result.getString(PerpsCloseBottomSheetDialogFragment.RESULT_POSITION_ID)) + assertEquals("3.125", result.getString(PerpsCloseBottomSheetDialogFragment.RESULT_AMOUNT)) + } + } + + private fun assertRestoredResult(original: Fragment, requestKey: String, send: (Fragment) -> Unit, check: (Bundle) -> Unit) { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).create() + val activity = controller.get() + val manager = activity.supportFragmentManager + val restored = restoreArguments(original) + ReflectionHelpers.setField(restored, "mFragmentManager", manager) + try { + send(restored) + manager.setFragmentResultListener(PerpsAdjustBottomSheetDialogFragment.resultKey("other-position"), activity) { _, _ -> + fail("A result must not reach another position") + } + var deliveries = 0 + manager.setFragmentResultListener(requestKey, activity) { _, result -> + deliveries++ + check(result) + } + assertEquals(0, deliveries) + controller.start().resume() + assertEquals(1, deliveries) + manager.clearFragmentResultListener(requestKey) + manager.setFragmentResultListener(requestKey, activity) { _, _ -> deliveries++ } + assertEquals(1, deliveries) + } finally { + controller.pause().stop().destroy() + } + } + + private fun restoreArguments(original: Fragment): Fragment { + val restored = FragmentFactory().instantiate(original.javaClass.classLoader!!, original.javaClass.name) + val parcel = Parcel.obtain() + try { + original.requireArguments().writeToParcel(parcel, 0) + parcel.setDataPosition(0) + restored.arguments = Bundle.CREATOR.createFromParcel(parcel) + } finally { + parcel.recycle() + } + return restored + } +} diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginInputTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginInputTest.kt new file mode 100644 index 0000000000..c594d7642c --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginInputTest.kt @@ -0,0 +1,18 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class PerpsMarginInputTest { + @Test + fun externalAmountChangesMoveCursorToEndAndManualEditsKeepSelection() { + val editing = TextFieldValue("25", TextRange(1), TextRange(0, 2)) + assertSame(editing, syncPerpsMarginInput(editing, "25")) + for (input in listOf("50", "100", "5.00", "")) { + assertEquals(TextFieldValue(input, TextRange(input.length)), syncPerpsMarginInput(editing, input)) + } + } +} From 81095c9aca0be448c5b125495f1ff1cb76a04279 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 17 Sep 2026 17:25:19 +0800 Subject: [PATCH 12/29] fix(wallet): align cash and earn account card styles (#6684) * fix(wallet): align cash and earn account card styles * fix(wallet): stack cash and earn account cards * fix(wallet): keep account cards side by side --- .../home/components/WalletHomeAccountCards.kt | 205 ++++++++++-------- .../wallet/home/components/WalletHomeCard.kt | 26 +-- 2 files changed, 122 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeAccountCards.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeAccountCards.kt index f2deb087e4..f0792bf534 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeAccountCards.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeAccountCards.kt @@ -18,9 +18,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -103,38 +106,37 @@ private fun CompactCashAccountCard( AccountCardSurface( modifier = modifier .clickable { callbacks.onCashClicked() }, - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + contentPadding = PaddingValues(16.dp), ) { - Row( - modifier = Modifier.heightIn(min = 18.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.cash_balance), - color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, - lineHeight = 14.sp, - fontWeight = FontWeight.W400, - style = AccountTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.cash_balance), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.W400, + style = AccountTextStyle, + modifier = Modifier.heightIn(min = 16.dp).wrapContentHeight(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) WalletHomeAccountBalance( balanceAmountText = account.balanceAmountText, modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.height(10.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Box(modifier = Modifier.weight(1f)) { - account.apyText?.let { apyText -> - AccountApy( - text = stringResource(R.string.cash_account_apy, apyText), - quoteColorReversed = quoteColorReversed, - ) - } - } + Spacer(modifier = Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + account.apyText?.let { apyText -> + AccountApy( + text = stringResource(R.string.cash_account_apy, apyText), + quoteColorReversed = quoteColorReversed, + modifier = Modifier.weight(1f, fill = false), + ) + } ?: Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.width(4.dp)) AccountArrow() } @@ -152,9 +154,12 @@ private fun CompactEarnAccountCard( AccountCardSurface( modifier = modifier .clickable { callbacks.onEarnAccountClicked() }, - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + contentPadding = PaddingValues(16.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( text = stringResource(R.string.earn_balance), color = MixinAppTheme.colors.textAssist, @@ -164,26 +169,30 @@ private fun CompactEarnAccountCard( style = AccountTextStyle, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier.weight(1f), ) Spacer(modifier = Modifier.width(4.dp)) - EarnTokenIcons(accounts) + EarnTokenIcons(accounts, modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) WalletHomeAccountBalance( balanceAmountText = account.balanceAmountText, modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.height(10.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Box(modifier = Modifier.weight(1f)) { - account.maxApyText?.let { apyText -> - AccountApy( - text = stringResource(R.string.up_to_apy, apyText), - quoteColorReversed = quoteColorReversed, - ) - } - } + Spacer(modifier = Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + account.maxApyText?.let { apyText -> + AccountApy( + text = stringResource(R.string.up_to_apy, apyText), + quoteColorReversed = quoteColorReversed, + modifier = Modifier.weight(1f, fill = false), + ) + } ?: Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.width(4.dp)) AccountArrow() } @@ -200,7 +209,7 @@ private fun EarnAccountCard( val account = accounts.summary() AccountCardSurface( modifier = modifier.clickable { callbacks.onEarnAccountClicked() }, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 20.dp), + contentPadding = PaddingValues(start = 16.dp, top = 20.dp, end = 20.dp, bottom = 20.dp), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -211,30 +220,25 @@ private fun EarnAccountCard( contentDescription = null, modifier = Modifier.size(42.dp), ) - Spacer(modifier = Modifier.size(12.dp)) + Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().heightIn(min = 18.dp), verticalAlignment = Alignment.CenterVertically, ) { - Row( + Text( + text = stringResource(R.string.earn_balance), + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.W400, + style = AccountTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.earn_balance), - color = MixinAppTheme.colors.textMinor, - fontSize = 14.sp, - lineHeight = 17.sp, - fontWeight = FontWeight.W400, - style = AccountTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - Spacer(modifier = Modifier.width(4.dp)) - EarnTokenIcons(accounts) - } + ) + Spacer(modifier = Modifier.width(4.dp)) + EarnTokenIcons(accounts) Spacer(modifier = Modifier.width(4.dp)) AccountArrow() } @@ -266,7 +270,7 @@ internal fun WalletHomeAccountBalance( modifier: Modifier = Modifier, ) { Row( - modifier = modifier, + modifier = modifier.heightIn(min = 22.dp), verticalAlignment = Alignment.Bottom, ) { BasicText( @@ -277,7 +281,7 @@ internal fun WalletHomeAccountBalance( style = AccountTextStyle.copy( color = MixinAppTheme.colors.textPrimary, fontSize = 18.sp, - lineHeight = 21.sp, + lineHeight = 22.sp, fontWeight = FontWeight.W600, ), maxLines = 1, @@ -303,6 +307,28 @@ internal fun WalletHomeAccountBalance( } } +@Composable +internal fun AccountApy( + text: String, + quoteColorReversed: Boolean, + modifier: Modifier = Modifier, +) { + val color = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + Text( + text = text, + color = color, + fontSize = 12.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.W400, + style = AccountTextStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier + .background(color.copy(alpha = 0.1f), RoundedCornerShape(4.dp)) + .padding(horizontal = 3.dp, vertical = 1.dp), + ) +} + @Composable private fun AccountCardSurface( modifier: Modifier, @@ -330,37 +356,28 @@ internal fun AccountArrow() { } @Composable -private fun EarnTokenIcons(accounts: List) { - Row(horizontalArrangement = Arrangement.spacedBy((-4).dp)) { +private fun EarnTokenIcons( + accounts: List, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.requiredHeight(18.dp), + horizontalArrangement = Arrangement.spacedBy((-6).dp), + ) { accounts.distinctBy { it.assetId }.take(2).forEach { account -> - WalletAssetIcon( - iconUrl = account.iconUrl, - chainIconUrl = null, - collectionHash = null, - size = 18.dp, - chainBadgeSize = 6.dp, - ) + Box( + modifier = Modifier + .size(18.dp) + .background(MixinAppTheme.colors.background, CircleShape) + .padding(1.dp), + ) { + WalletAssetIcon( + iconUrl = account.iconUrl, + chainIconUrl = null, + collectionHash = null, + size = 16.dp, + ) + } } } } - -@Composable -private fun AccountApy( - text: String, - quoteColorReversed: Boolean, -) { - val color = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen - Text( - text = text, - color = color, - fontSize = 12.sp, - lineHeight = 14.sp, - fontWeight = FontWeight.W500, - style = AccountTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .background(color.copy(alpha = 0.1f), RoundedCornerShape(4.dp)) - .padding(horizontal = 3.dp, vertical = 1.dp), - ) -} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeCard.kt b/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeCard.kt index 9bbb602d37..9ae8ec1776 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeCard.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/home/components/WalletHomeCard.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -70,6 +71,7 @@ internal fun WalletHomeCard( val contentPadding = when { card.hasSelfPaddedItems() -> Modifier card == WalletHomeCardType.BALANCE -> Modifier.padding(top = 20.dp, bottom = 12.dp) + card == WalletHomeCardType.CASH -> Modifier.padding(start = 16.dp, top = 20.dp, end = 20.dp, bottom = 20.dp) else -> Modifier.padding(20.dp) } Column( @@ -97,7 +99,7 @@ internal fun WalletHomeCard( WalletHomeCardType.BANNER -> Unit WalletHomeCardType.CASH -> CashAccountCard( cashAccount = state.cashAccount, - callbacks = callbacks, + quoteColorReversed = state.quoteColorReversed, ) WalletHomeCardType.ACCOUNTS -> Unit WalletHomeCardType.POSITIONS -> SectionCard( @@ -199,7 +201,7 @@ internal fun WalletHomeCard( @Composable private fun CashAccountCard( cashAccount: WalletHomeCashAccount?, - callbacks: WalletHomeCallbacks, + quoteColorReversed: Boolean, ) { if (cashAccount == null) return @@ -213,14 +215,14 @@ private fun CashAccountCard( contentDescription = null, modifier = Modifier.size(42.dp), ) - Spacer(modifier = Modifier.width(14.dp)) + Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(modifier = Modifier.heightIn(min = 18.dp), verticalAlignment = Alignment.CenterVertically) { Text( text = stringResource(R.string.cash_balance), - color = MixinAppTheme.colors.textPrimary, - fontSize = 14.sp, - lineHeight = 17.sp, + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + lineHeight = 14.sp, fontWeight = FontWeight.W400, style = CashAccountTextStyle, maxLines = 1, @@ -238,15 +240,9 @@ private fun CashAccountCard( ) cashAccount.apyText?.let { apyText -> Spacer(modifier = Modifier.width(10.dp)) - Text( + AccountApy( text = stringResource(R.string.cash_account_apy, apyText), - color = Color(0xFF5ECF72), - fontSize = 14.sp, - lineHeight = 16.sp, - fontWeight = FontWeight.W400, - style = CashAccountTextStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + quoteColorReversed = quoteColorReversed, ) } } From a9b3c63fd536fac782c8636594dde236a7bbf9fa Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 18 Sep 2026 14:59:07 +0800 Subject: [PATCH 13/29] fix(perps): align margin adjustments with ios --- .../trade/perps/LiquidationPriceRequest.kt | 11 +- .../home/web3/trade/perps/OpenPositionPage.kt | 5 +- .../web3/trade/perps/PerpetualViewModel.kt | 5 +- .../PerpsAddBottomSheetDialogFragment.kt | 5 +- .../PerpsAdjustBottomSheetDialogFragment.kt | 2 +- .../PerpsCloseBottomSheetDialogFragment.kt | 26 +---- .../PerpsConfirmBottomSheetDialogFragment.kt | 50 ++++---- .../web3/trade/perps/PerpsMarginAmount.kt | 22 ++++ .../PerpsMarginBottomSheetDialogFragment.kt | 107 ++++++++++-------- app/src/main/res/values-es/strings.xml | 7 +- app/src/main/res/values-in/strings.xml | 7 +- app/src/main/res/values-ja/strings.xml | 7 +- app/src/main/res/values-ko/strings.xml | 7 +- app/src/main/res/values-ms/strings.xml | 7 +- app/src/main/res/values-ru/strings.xml | 7 +- app/src/main/res/values-zh-rCN/strings.xml | 7 +- app/src/main/res/values-zh-rTW/strings.xml | 7 +- app/src/main/res/values/strings.xml | 7 +- .../perps/LiquidationPriceRequestTest.kt | 16 +-- .../web3/trade/perps/PerpsMarginAmountTest.kt | 25 ++++ .../web3/trade/perps/PerpsMarginTokenTest.kt | 15 +++ 21 files changed, 224 insertions(+), 128 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt index f12b0926bb..400778c653 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt @@ -19,7 +19,7 @@ internal sealed interface LiquidationPriceResult { data object Retry : LiquidationPriceResult - data object Failure : LiquidationPriceResult + data class Failure(val message: String?) : LiquidationPriceResult } internal fun liquidationPriceResult( @@ -27,6 +27,7 @@ internal fun liquidationPriceResult( errorCode: Int?, limit: LiquidationPriceLimit = LiquidationPriceLimit(null, null), availableMargin: BigDecimal? = null, + errorMessage: String? = null, ): LiquidationPriceResult { val validPrice = price?.takeIf { it.isNotBlank() } return when { @@ -36,7 +37,7 @@ internal fun liquidationPriceResult( LiquidationPriceResult.LimitExceeded(limit) } errorCode == 500 -> LiquidationPriceResult.Retry - else -> LiquidationPriceResult.Failure + else -> LiquidationPriceResult.Failure(errorMessage) } } @@ -70,11 +71,15 @@ internal suspend fun requestLiquidationPrice( retryDelayMillis: Long = 1000L, onLimitExceeded: (LiquidationPriceLimit) -> Unit = {}, onMarginExceeded: (BigDecimal?) -> Unit = {}, + onFailure: (String?) -> Unit = {}, request: suspend () -> LiquidationPriceResult, ): String? { while (true) { when (val result = request()) { - LiquidationPriceResult.Failure -> return null + is LiquidationPriceResult.Failure -> { + onFailure(result.message) + return null + } is LiquidationPriceResult.LimitExceeded -> { onLimitExceeded(result.limit) return null 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 5e698a48d5..1b2c4b3753 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 @@ -143,6 +143,7 @@ fun OpenPositionPage( var stopLossPrice by remember { mutableStateOf("") } var remoteLiquidationPrice by remember { mutableStateOf(null) } var liquidationPriceLimit by remember { mutableStateOf(null) } + var liquidationError by remember { mutableStateOf(null) } var isLiquidationLoading by remember { mutableStateOf(false) } var errorInfo by remember { mutableStateOf(null) } var isProcessing by remember { mutableStateOf(false) } @@ -205,6 +206,7 @@ fun OpenPositionPage( LaunchedEffect(selectedIsLong, usdtAmount, leverage, currentMarket.minAmount) { liquidationPriceLimit = null + liquidationError = null val amount = usdtAmount.toBigDecimalOrNull() val minimumAmount = currentMarket.minAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO if (!shouldRequestLiquidationPrice(amount, minimumAmount)) { @@ -224,6 +226,7 @@ fun OpenPositionPage( .let { limitTradeInputDecimalPlaces(it, TRADE_INPUT_MAX_DECIMAL_PLACES) } remoteLiquidationPrice = requestLiquidationPrice( onLimitExceeded = { liquidationPriceLimit = it }, + onFailure = { liquidationError = it ?: context.getString(R.string.Data_error) }, ) { viewModel.estimateLiquidationPrice( marketId = currentMarket.marketId, @@ -284,7 +287,7 @@ fun OpenPositionPage( aboveMaximumMargin -> maximumMarginError else -> null } - val displayedErrorInfo = errorInfo?.takeIf { it.isNotBlank() } ?: marginLimitError ?: liquidationLimitError + val displayedErrorInfo = errorInfo?.takeIf { it.isNotBlank() } ?: marginLimitError ?: liquidationLimitError ?: liquidationError val tokenNetworkName = currentToken?.chainName ?.takeIf { it.isNotBlank() } ?: currentToken?.chainSymbol diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index 866b454941..6d0f01b53f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -416,16 +416,17 @@ class PerpetualViewModel @Inject constructor( errorCode = response.errorCode, limit = parseLiquidationPriceLimit(response.error?.extra), availableMargin = availableMarginFromError(response.error?.extra), + errorMessage = MixinApplication.appContext.getMixinErrorStringByCode(response.errorCode, response.errorDescription), ) } } catch (e: CancellationException) { throw e } catch (e: HttpException) { Timber.e(e, "HTTP error estimating liquidation price") - liquidationPriceResult(price = null, errorCode = e.code()) + liquidationPriceResult(price = null, errorCode = e.code(), errorMessage = ErrorHandler.getErrorMessage(e)) } catch (e: Exception) { Timber.e(e, "Error estimating liquidation price") - LiquidationPriceResult.Failure + LiquidationPriceResult.Failure(ErrorHandler.getErrorMessage(e)) } } 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 ee97a6d47b..e5f5bd8b3d 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 @@ -300,6 +300,7 @@ private fun PerpsAddContent( } var remoteLiquidationPrice by remember(position.positionId) { mutableStateOf(null) } var liquidationPriceLimit by remember(position.positionId) { mutableStateOf(null) } + var liquidationError by remember(position.positionId) { mutableStateOf(null) } var isLiquidationLoading by remember(position.positionId) { mutableStateOf(false) } var liquidationJob by remember(position.positionId) { mutableStateOf(null) } val tokenBalance = selectedToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO @@ -328,6 +329,7 @@ private fun PerpsAddContent( LaunchedEffect(amount, belowMinimumMargin, aboveMaximumMargin, position.updatedAt, market?.last) { liquidationPriceLimit = null + liquidationError = null val addMargin = amount.toBigDecimalOrNull() if (!shouldRequestLiquidationPrice(addMargin, minimumMargin) || aboveMaximumMargin) { liquidationJob?.cancel() @@ -347,6 +349,7 @@ private fun PerpsAddContent( .let { limitTradeInputDecimalPlaces(it, TRADE_INPUT_MAX_DECIMAL_PLACES) } remoteLiquidationPrice = requestLiquidationPrice( onLimitExceeded = { liquidationPriceLimit = it }, + onFailure = { liquidationError = it ?: context.getString(R.string.Data_error) }, ) { viewModel.estimateLiquidationPrice( amount = normalizedAmount, @@ -386,7 +389,7 @@ private fun PerpsAddContent( val marginLimitError = when { belowMinimumMargin -> minimumMarginError aboveMaximumMargin -> maximumMarginError - else -> liquidationLimitError + else -> liquidationLimitError ?: liquidationError } val currentPriceText = formatPerpsPrice(currentPrice, priceScale) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt index dc7fa28d97..3168496519 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt @@ -94,7 +94,7 @@ private fun PerpsAdjustContent( Column( modifier = Modifier .fillMaxWidth() - .background(MixinAppTheme.colors.backgroundWindow) + .background(MixinAppTheme.colors.background) .verticalScroll(rememberScrollState()) .padding(bottom = 40.dp), ) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index e1152d5453..a7953fbbd3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -323,8 +323,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen id = when (step) { Step.Pending -> if (isReduceMargin) R.string.perps_confirm_reduce_margin_title else R.string.confirm_closing_position Step.Done -> if (isReduceMargin) R.string.perps_margin_submitted else R.string.Position_Closed - Step.Error -> if (isReduceMargin) R.string.Failed else if (isLong) R.string.Closed_Long_Failed else R.string.Closed_Short_Failed - Step.Sending -> R.string.Sending + Step.Error -> if (isReduceMargin) R.string.perps_reducing_margin_failed else if (isLong) R.string.Closed_Long_Failed else R.string.Closed_Short_Failed + Step.Sending -> if (isReduceMargin) R.string.perps_reducing_margin else R.string.Sending } ), style = TextStyle( @@ -425,20 +425,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) } Box(modifier = Modifier.height(20.dp)) - if (isReduceMargin) { - Text( - text = stringResource(R.string.Amount).uppercase(), - color = MixinAppTheme.colors.textRemarks, - fontSize = 14.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = formatPerpsExactUsdDecimal(estimatedReceive), - color = MixinAppTheme.colors.textPrimary, - fontSize = 16.sp, - ) - Spacer(modifier = Modifier.height(20.dp)) - } settleAssetItem?.let { asset -> Text( text = stringResource(R.string.Estimated_Receive).uppercase(), @@ -458,9 +444,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Spacer(modifier = Modifier.width(6.dp)) Text( text = if (isReduceMargin) { - val price = asset.priceUsd.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } - price?.let { "${estimatedReceive.divide(it, 8, RoundingMode.DOWN).stripTrailingZeros().toPlainString()} ${asset.symbol}" } - ?: formatPerpsExactUsdDecimal(estimatedReceive) + "+${formatPerpsQuantity(estimatedReceive)} ${asset.symbol}" } else "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, @@ -631,8 +615,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private fun refreshAssetAndSender(settleAssetId: String?, botId: String?) { lifecycleScope.launch { - settleAssetId?.let { assetId -> - val asset = bottomViewModel.findAssetItemById(assetId) + settleAssetId?.takeIf { it.isNotBlank() }?.let { assetId -> + val asset = if (isReduceMargin) bottomViewModel.findOrSyncAsset(assetId) else bottomViewModel.findAssetItemById(assetId) asset?.let { settleAssetSymbol = it.symbol settleAssetItem = it diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt index 0565fed91e..cea9531d52 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt @@ -119,6 +119,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private const val ARGS_PAY_URL = "args_pay_url" private const val ARGS_IS_ADD_POSITION = "args_is_add_position" private const val ARGS_IS_ADD_MARGIN = "args_is_add_margin" + private const val ARGS_MARGIN_BEFORE = "args_margin_before" private const val ARGS_ORDER_ERROR = "args_order_error" private const val ARGS_MARKET_ID = "args_market_id" private const val ARGS_ADD_POSITION_ID = "args_add_position_id" @@ -174,6 +175,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm payUrl: String?, isAddPosition: Boolean = false, isAddMargin: Boolean = false, + marginBefore: String? = null, ): PerpsConfirmBottomSheetDialogFragment { return PerpsConfirmBottomSheetDialogFragment().withArgs { putString(ARGS_MARKET_SYMBOL, marketSymbol) @@ -191,6 +193,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm putString(ARGS_PAY_URL, payUrl) putBoolean(ARGS_IS_ADD_POSITION, isAddPosition) putBoolean(ARGS_IS_ADD_MARGIN, isAddMargin) + putString(ARGS_MARGIN_BEFORE, marginBefore) } } } @@ -243,6 +246,8 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private val priceScale by lazy { requireArguments().getInt(ARGS_PRICE_SCALE, 2) } private val isAddPosition by lazy { requireArguments().getBoolean(ARGS_IS_ADD_POSITION) } private val isAddMargin by lazy { requireArguments().getBoolean(ARGS_IS_ADD_MARGIN) } + private val marginBefore by lazy { requireArguments().getString(ARGS_MARGIN_BEFORE) } + private val marginAfter by lazy { marginAfterAdjustment(marginBefore, amount, increase = true) } private val payUrl by lazy { requireArguments().getString(ARGS_PAY_URL) } private val entryFiatPrice by lazy { @@ -355,14 +360,14 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm else -> R.string.Position_Submitted } Step.Error -> when { - isAddMargin -> R.string.Failed + isAddMargin -> R.string.perps_adding_margin_failed isAddPosition && isLong -> R.string.Added_Long_Failed isAddPosition -> R.string.Added_Short_Failed orderError != null -> R.string.position_opening_failed isLong -> R.string.Opened_Long_Failed else -> R.string.Opened_Short_Failed } - Step.Sending -> R.string.Sending + Step.Sending -> if (isAddMargin) R.string.perps_adding_margin else R.string.Sending } ), style = TextStyle( @@ -449,7 +454,10 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm if (amount.isNotBlank()) { PerpsInfoItem( title = stringResource(R.string.Amount).uppercase(), - value = "$amount $tokenSymbol" + value = "$amount $tokenSymbol", + subValue = if (isAddMargin && marginAfter != null) { + "${stringResource(R.string.perps_total_margin)} ${formatPerpsMarginAmount(marginBefore?.toBigDecimalOrNull())} → ${formatPerpsMarginAmount(marginAfter)}" + } else null, ) Box(modifier = Modifier.height(20.dp)) } @@ -500,31 +508,19 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } } - if (!isAddMargin && (orderError == null || rawLiquidationPrice != null)) { - val lossPercent = remember(leverage) { - val percent = String.format("%.2f", 100.0 / leverage) - Timber.d("LossPercent - leverage: $leverage, lossPercent: $percent") - percent - } - - val lossSubValue = if (isLong) { - val text = stringResource( - R.string.Price_Down_Loss, - lossPercent, - amount, - tokenSymbol - ) - Timber.d("LossSubValue (Long) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") - text + if (orderError == null || rawLiquidationPrice != null) { + val lossPercent = if (isAddMargin) { + marginLiquidationLossPercent(entryPrice, rawLiquidationPrice)?.let { formatPerpsQuantity(it) } } else { - val text = stringResource( - R.string.Price_Up_Loss, - lossPercent, - amount, - tokenSymbol - ) - Timber.d("LossSubValue (Short) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") - text + String.format("%.2f", 100.0 / leverage) + } + val lossSubValue = lossPercent?.let { + stringResource( + if (isLong) R.string.Price_Down_Loss else R.string.Price_Up_Loss, + it, + if (isAddMargin) formatPerpsMarginAmount(marginAfter) else amount, + if (isAddMargin) "" else tokenSymbol, + ).trimEnd() } PerpsInfoItem( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt index e9470a3fa5..7368b85ac8 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt @@ -1,12 +1,34 @@ package one.mixin.android.ui.home.web3.trade.perps import com.google.gson.JsonElement +import one.mixin.android.extension.numberFormat8 import one.mixin.android.ui.home.web3.trade.TRADE_INPUT_MAX_DECIMAL_PLACES +import one.mixin.android.vo.safe.TokenItem import java.math.BigDecimal import java.math.RoundingMode import kotlin.math.abs import kotlin.math.roundToInt +internal fun perpsMarginTokenBalance(token: TokenItem?): BigDecimal? = token?.balance?.toBigDecimalOrNull() + +internal fun formatPerpsMarginAmount(value: BigDecimal?): String = + if (value == null || value.setScale(2, RoundingMode.HALF_UP).signum() == 0) "0" else formatPerpsPrice(value, 2) + +internal fun formatPerpsMarginLimit(currentMargin: String?, availableMargin: BigDecimal, isPercentage: Boolean): String { + if (isPercentage) { + val percentage = marginReductionPercentage(currentMargin, availableMargin) ?: BigDecimal.ZERO + return "${percentage.setScale(2, RoundingMode.DOWN).numberFormat8()}%" + } + val amount = availableMargin.setScale(TRADE_INPUT_MAX_DECIMAL_PLACES, RoundingMode.DOWN) + return if (amount.signum() == 0) "0" else "$PERPS_USD_SYMBOL${amount.numberFormat8()}" +} + +internal fun marginLiquidationLossPercent(entryPrice: String, liquidationPrice: String?): BigDecimal? { + val entry = entryPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null + val liquidation = liquidationPrice?.toBigDecimalOrNull()?.takeIf { it >= BigDecimal.ZERO } ?: return null + return (entry - liquidation).abs().multiply(BigDecimal(100)).divide(entry, 2, RoundingMode.HALF_UP) +} + internal fun snapMarginReductionPercentage(value: Float): Int { val percentage = value.coerceIn(0f, 100f).roundToInt() val marker = (percentage / 25f).roundToInt() * 25 diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 24302e1541..2886c52af8 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -98,6 +99,8 @@ import one.mixin.android.session.Session import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment 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.TRADE_INPUT_MAX_DECIMAL_PLACES +import one.mixin.android.ui.home.web3.trade.limitTradeInputDecimalPlaces import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.util.ErrorHandler @@ -156,7 +159,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current var selectedToken by remember { mutableStateOf(null) } - var acceptedAssets by remember { mutableStateOf>(emptyList()) } + var availableTokens by remember { mutableStateOf?>(null) } + val acceptedAssets = availableTokens.orEmpty().map { it.assetId } var market by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } @@ -171,6 +175,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val amountValue = if (increase) marginAdjustmentAmount(amount) else reduceMarginAmount(currentPosition.margin, amount, inputIsPercentage) val normalizedAmount = amountValue?.stripTrailingZeros()?.toPlainString().orEmpty() var remoteLiquidationPrice by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } + var liquidationError by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } var isLiquidationLoading by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(false) } val totalMargin = marginAfterAdjustment(currentPosition.margin, normalizedAmount, increase, availableMargin) val marginValue = currentPosition.margin?.toBigDecimalOrNull()?.takeIf { it >= BigDecimal.ZERO } @@ -185,18 +190,14 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme reduceByPercent -> percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString().orEmpty() else -> amountValue?.let { formatMarginAdjustmentInput(it, false) }.orEmpty() } - val balanceUsd = selectedToken?.let { token -> - val balance = token.balance.toBigDecimalOrNull() ?: return@let null - val price = token.priceUsd.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return@let null - balance * price - } - val insufficientBalance = increase && amountValue != null && balanceUsd != null && amountValue > balanceUsd + val tokenBalance = perpsMarginTokenBalance(selectedToken) + val insufficientBalance = increase && amountValue != null && tokenBalance != null && amountValue > tokenBalance val isCurrentWallet = !currentPosition.walletId.isNullOrBlank() && currentPosition.walletId == Session.getAccountId() val canSubmit = !loading && !exceedsReductionLimit && position?.state == PerpsPosition.STATE_OPEN && totalMargin != null && !isLiquidationLoading && remoteLiquidationPrice != null && isCurrentWallet && if (increase) { - selectedToken?.assetId in acceptedAssets && balanceUsd != null && !insufficientBalance + selectedToken?.assetId in acceptedAssets && tokenBalance != null && !insufficientBalance } else { availableMargin?.let { amountValue != null && amountValue <= it } ?: true } @@ -209,9 +210,15 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme try { delay(200L) remoteLiquidationPrice = requestLiquidationPrice( + onFailure = { liquidationError = it ?: getString(R.string.Data_error) }, + onLimitExceeded = { liquidationError = getString(R.string.error_perps_position_size_exceeds_leverage_limit) }, onMarginExceeded = { availableMargin = it - error = if (it == null) getString(R.string.Data_error) else null + liquidationError = when { + increase -> requireContext().getMixinErrorStringByCode(10653, "") + it == null -> getString(R.string.Data_error) + else -> null + } }, ) { viewModel.estimateLiquidationPrice( @@ -238,13 +245,10 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val source = requireArguments().getString(ARGS_SOURCE).orEmpty() if (increase) { AnalyticsTracker.trackPerpsAddMarginStart(source) - viewModel.loadAcceptedAssets( - onSuccess = { ids -> - acceptedAssets = ids - viewModel.loadUsdTokens { tokens -> - selectedToken = tokens.filter { it.assetId in ids } - .maxByOrNull { it.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO } - } + viewModel.loadPerpsTokens( + onSuccess = { tokens -> + availableTokens = tokens + selectedToken = tokens.firstOrNull() }, onError = { error = it }, ) @@ -258,7 +262,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val value = marginAdjustmentAmount(submittedAmount) ?: return if (marginAfterAdjustment(currentPosition.margin, submittedAmount, increase, availableMargin) == null) return val token = selectedToken - if (increase && (token == null || token.assetId !in acceptedAssets || balanceUsd == null || value > balanceUsd)) return + val estimatedLiquidationPrice = remoteLiquidationPrice + if (increase && (token == null || token.assetId !in acceptedAssets || tokenBalance == null || value > tokenBalance || estimatedLiquidationPrice == null)) return if (increase) AnalyticsTracker.trackPerpsAddMarginPreview() loading = true isCancelable = false @@ -286,12 +291,14 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme isLong = currentPosition.side.equals("long", ignoreCase = true), amount = data.payAmount, leverage = currentPosition.leverage, - entryPrice = currentPosition.entryPrice, + entryPrice = market?.last ?: currentPosition.entryPrice, marginAssetPrice = token.priceUsd, tokenSymbol = token.symbol, - priceScale = currentPosition.priceScale, + liquidationPrice = estimatedLiquidationPrice, + priceScale = market?.priceScale ?: currentPosition.priceScale, payUrl = data.paymentUrl, isAddMargin = true, + marginBefore = currentPosition.margin, ).show(parentFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) } else { AnalyticsTracker.trackPerpsReduceMarginEnd() @@ -332,12 +339,10 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } MixinAppTheme { val reductionLimitError = if (exceedsReductionLimit && maximumReduction != null) { - val limit = if (reduceByPercent) { - "${marginReductionPercentage(currentPosition.margin, maximumReduction)?.setScale(0, RoundingMode.DOWN)?.toPlainString().orEmpty()}%" - } else formatPerpsPrice(maximumReduction, 2) - stringResource(R.string.perps_available_margin, limit) + val limit = formatPerpsMarginLimit(currentPosition.margin, maximumReduction, reduceByPercent) + stringResource(R.string.max_removable, limit) } else null - val errorText = if (insufficientBalance) stringResource(R.string.insufficient_balance) else error + val errorText = if (insufficientBalance) stringResource(R.string.insufficient_balance) else error ?: liquidationError val onSubmit = { if (increase) { submit(normalizedAmount) @@ -396,10 +401,11 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme InputContent( token = selectedToken?.toSwapToken(), text = amount, - selectClick = if (!loading) ({ + selectClick = if (!loading && availableTokens != null) ({ TokenListBottomSheetDialogFragment.newInstance( fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP, currentAssetId = selectedToken?.assetId, + perpsTokens = availableTokens, ).setOnAssetClick { token -> if (token.assetId in acceptedAssets) { selectedToken = token @@ -413,7 +419,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme inputFontSize = 24.sp, inputFontWeight = FontWeight.W500, autoFocus = true, - maxDecimalPlaces = 2, + maxDecimalPlaces = TRADE_INPUT_MAX_DECIMAL_PLACES, ) Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -427,8 +433,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme text = selectedToken?.balance?.numberFormat8() ?: "0", color = MixinAppTheme.colors.textAssist, fontSize = 12.sp, - modifier = Modifier.clickable(enabled = !loading && balanceUsd != null) { - balanceUsd?.let { changeAmount(formatMarginAdjustmentInput(it, false)) } + modifier = Modifier.clickable(enabled = !loading && tokenBalance != null) { + tokenBalance?.let { changeAmount(limitTradeInputDecimalPlaces(it.stripTrailingZeros().toPlainString())) } }, ) Spacer(Modifier.width(8.dp)) @@ -548,11 +554,10 @@ private fun PerpsMarginContent( inputContent() Spacer(Modifier.height(18.dp)) Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { - val currentMarginText = position.margin?.toBigDecimalOrNull()?.let { formatPerpsPrice(it, 2) } ?: "-" + val currentMarginText = position.margin?.toBigDecimalOrNull()?.let { formatPerpsMarginAmount(it) } ?: "-" PerpsAddInfoRow( title = stringResource(R.string.Margin), - value = if (totalMargin != null) "$currentMarginText → ${formatPerpsPrice(totalMargin, 2)}" else currentMarginText, - onTipClick = { onGuide(PerpetualGuideBottomSheetDialogFragment.TAB_LEVERAGE) }, + value = if (totalMargin != null) "$currentMarginText → ${formatPerpsMarginAmount(totalMargin)}" else currentMarginText, ) Spacer(Modifier.height(16.dp)) PerpsAddInfoRow( @@ -589,15 +594,16 @@ private fun PerpsMarginActions( onSubmit: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth().background(MixinAppTheme.colors.background)) { - errorText?.let { - Text( - text = it, - color = MixinAppTheme.colors.walletRed, - fontSize = 14.sp, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), - ) - } + Text( + text = errorText.orEmpty(), + color = MixinAppTheme.colors.walletRed, + fontSize = 14.sp, + textAlign = TextAlign.Center, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), + ) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 12.dp, bottom = 20.dp), @@ -731,9 +737,9 @@ private fun PerpsReduceMarginInput( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (isPercentage) formatPerpsPrice(amount, 2) else "${percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString() ?: "0"}%", + text = if (isPercentage) formatPerpsMarginAmount(amount) else "${percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString() ?: "0"}%", color = MixinAppTheme.colors.textAssist, - fontSize = 12.sp, + fontSize = 16.sp, ) Spacer(Modifier.width(4.dp)) Icon( @@ -743,9 +749,16 @@ private fun PerpsReduceMarginInput( modifier = Modifier.size(16.dp), ) } - errorText?.let { - Text(it, color = MixinAppTheme.colors.walletRed, fontSize = 12.sp, textAlign = TextAlign.Center) - } + Text( + text = errorText.orEmpty(), + color = MixinAppTheme.colors.walletRed, + fontSize = 12.sp, + textAlign = TextAlign.Center, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) Spacer(Modifier.height(12.dp)) Slider( value = sliderPercentage, @@ -754,7 +767,6 @@ private fun PerpsReduceMarginInput( valueRange = 0f..100f, steps = 99, modifier = Modifier.fillMaxWidth().height(32.dp).drawWithContent { - drawContent() val trackInset = 10.dp.toPx() for (value in 0..100 step 25) { val fraction = if (layoutDirection == LayoutDirection.Rtl) 1f - value / 100f else value / 100f @@ -764,6 +776,7 @@ private fun PerpsReduceMarginInput( center = Offset(trackInset + (size.width - 2 * trackInset) * fraction, size.height / 2), ) } + drawContent() }, colors = SliderDefaults.colors( thumbColor = MixinAppTheme.colors.accent, @@ -776,7 +789,7 @@ private fun PerpsReduceMarginInput( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { listOf(0, 25, 50, 75, 100).forEach { value -> Text( - text = if (isPercentage) "$value%" else formatPerpsPrice(reduceMarginAmount(margin?.toPlainString(), value.toString(), true), 2), + text = if (isPercentage) "$value%" else formatPerpsMarginAmount(reduceMarginAmount(margin?.toPlainString(), value.toString(), true)), color = MixinAppTheme.colors.textAssist, fontSize = 10.sp, textAlign = when (value) { 0 -> TextAlign.Start; 100 -> TextAlign.End; else -> TextAlign.Center }, diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4d9955083c..c638dfbb76 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -394,7 +394,7 @@ ERROR 10006: Actualiza Mixin(%1$s) para seguir usando el servicio. No puedo encontrar una aplicación de mapas Por favor abre los permisos necesarios - ERROR %1$d: El tamaño de la posición es demasiado pequeño. + ERROR %1$s: El tamaño de la posición es demasiado pequeño. ERROR 20110: Número de teléfono no válido ERROR 10003: Error al entregar SMS ERROR 20114: Código de verificación del teléfono caducado @@ -1194,10 +1194,13 @@ Aumentar la posición amplifica las ganancias y los riesgos Si el margen es bajo, añade margen primero Margen total - Margen disponible para retirar: %1$s Ajuste de margen enviado Confirmar adición de margen + Añadiendo margen + No se pudo añadir margen Confirmar reducción del margen + Reduciendo margen + No se pudo reducir el margen Añadir margen a %1$s %2$s Reducir margen para %1$s %2$s Reducir diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ebba248d6f..588be2c4f4 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -246,7 +246,7 @@ KESALAHAN 10006: Harap perbarui Mixin(%1$s) untuk terus menggunakan layanan. Tidak dapat menemukan aplikasi peta Harap buka izin yang diperlukan - KESALAHAN %1$d: Ukuran posisi terlalu kecil. + KESALAHAN %1$s: Ukuran posisi terlalu kecil. KESALAHAN 20110: Nomor telepon tidak valid KESALAHAN 10003: Gagal mengirim SMS KESALAHAN 20114: Kode verifikasi telepon sudah tidak berlaku @@ -752,10 +752,13 @@ Menambah posisi memperbesar keuntungan dan risiko Jika margin rendah, tambahkan margin terlebih dahulu Total Margin - Margin yang dapat ditarik: %1$s Penyesuaian margin telah diajukan Konfirmasi Penambahan Margin + Menambahkan Margin + Gagal Menambahkan Margin Konfirmasi Pengurangan Margin + Mengurangi Margin + Gagal Mengurangi Margin Tambah Margin ke %1$s %2$s Kurangi Margin untuk %1$s %2$s Kurangi diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 13032ea245..4599cd8996 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -370,7 +370,7 @@ エラー 10006:このサービスを引き続き使用するには、Mixin(%1$s)をアップデートしてください。 地図アプリがありません 必要な権限を開いてください - エラー %1$d:ポジションサイズが小さすぎます。 + エラー %1$s:ポジションサイズが小さすぎます。 エラー 20110:無効な電話番号です エラー 10003:SMSの送信に失敗しました 期限切れ @@ -1140,10 +1140,13 @@ ポジションを増やすと利益とリスクが拡大します 証拠金が不足している場合は、先に証拠金を追加してください 証拠金合計 - 引き出し可能な証拠金:%1$s 証拠金の調整を送信しました 証拠金の追加を確認 + 証拠金を追加中 + 証拠金の追加に失敗しました 証拠金の減少を確認 + 証拠金を減らしています + 証拠金の減少に失敗しました %1$s %2$s に証拠金を追加 %1$s %2$s の証拠金を減らす 減らす diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 34d2281995..fe641f51bf 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -484,7 +484,7 @@ 오류 10614: 입력 금액이 너무 작거나 너무 큽니다. 금액을 조정하세요. 오류 10614: 금액이 %1$s의 최대 허용 값을 초과했습니다. 금액을 조정해주세요. 지정가 주문을 해보시겠어요? 금액이나 토큰에 제한이 없습니다. 오류 10615: 이 거래 쌍은 현재 지원되지 않습니다. 다른 토큰으로 전환해 보십시오. - 오류 %1$d: 포지션 크기가 너무 작습니다. + 오류 %1$s: 포지션 크기가 너무 작습니다. 오류 10651: 이미 오픈 포지션이 있습니다. 오류 10652: 현재 시장에서 아직 다른 주문을 처리 중입니다. 나중에 다시 시도하십시오. 오류 10655: 포지션 크기가 선택한 레버리지 한도를 초과했습니다. 금액 또는 레버리지를 낮추세요. @@ -2676,10 +2676,13 @@ 포지션을 늘리면 수익과 위험이 커집니다 증거금이 부족하면 먼저 증거금을 추가하세요 총 증거금 - 인출 가능한 증거금: %1$s 증거금 조정이 제출되었습니다 증거금 추가 확인 + 증거금 추가 중 + 증거금 추가 실패 증거금 감소 확인 + 증거금 감소 중 + 증거금 감소 실패 %1$s %2$s에 증거금 추가 %1$s %2$s의 증거금 감소 감소 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index e9b80cb183..78987168c0 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -265,7 +265,7 @@ RALAT 10006: Sila kemas kini Mixin(%1$s) untuk terus menggunakan perkhidmatan ini. Tidak dapat mencari aplikasi peta Sila buka kebenaran yang diperlukan - RALAT %1$d: Saiz posisi terlalu kecil. + RALAT %1$s: Saiz posisi terlalu kecil. RALAT 20110: Nombor telefon tidak sah RALAT 10003: Gagal menghantar SMS RALAT 20114: Kod pengesahan telefon yang telah tamat tempoh @@ -817,10 +817,13 @@ Menambah posisi meningkatkan keuntungan dan risiko Jika margin rendah, tambah margin dahulu Jumlah Margin - Margin yang boleh dikeluarkan: %1$s Pelarasan margin dihantar Sahkan Penambahan Margin + Menambah Margin + Gagal Menambah Margin Sahkan Pengurangan Margin + Mengurangkan Margin + Gagal Mengurangkan Margin Tambah Margin pada %1$s %2$s Kurangkan Margin untuk %1$s %2$s Kurangkan diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ed5da30bef..d57e5b611a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -377,7 +377,7 @@ ОШИБКА 10006: Обновите Mixin(%1$s), чтобы продолжить использование сервиса. Не могу найти приложение карты Пожалуйста, Откройте необходимые разрешения - ОШИБКА %1$d: Размер позиции слишком мал. + ОШИБКА %1$s: Размер позиции слишком мал. ОШИБКА 20110: Неверный номер телефона ОШИБКА 10003: Не удалось доставить SMS ОШИБКА 20114: Срок действия кода подтверждения телефона истек @@ -1135,10 +1135,13 @@ Увеличение позиции повышает прибыль и риски Если маржи мало, сначала добавьте маржу Общая маржа - Маржа, доступная для вывода: %1$s Запрос на изменение маржи отправлен Подтвердить добавление маржи + Добавление маржи + Не удалось добавить маржу Подтвердить уменьшение маржи + Уменьшение маржи + Не удалось уменьшить маржу Добавить маржу в %1$s %2$s Уменьшить маржу для %1$s %2$s Уменьшить diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1c607873bc..e5c4827ad7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -480,7 +480,7 @@ 错误 10614: 输入金额太小或太大,请重新输入。 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 - 错误 %1$d: 仓位规模太小,请调整后重试。 + 错误 %1$s: 仓位规模太小,请调整后重试。 错误 10651:当前已有持仓 当前市场已开仓。 错误 10655:仓位规模超过当前杠杆档位限制,请降低金额或杠杆。 @@ -2625,10 +2625,13 @@ 加仓会放大收益和风险 保证金不足时,优先添加保证金 总保证金 - 可减少保证金:%1$s 保证金调整已提交 确认添加保证金 + 正在添加保证金 + 添加保证金失败 确认减少保证金 + 正在减少保证金 + 减少保证金失败 %1$s %2$s 添加保证金 %1$s %2$s 减少保证金 减少 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index bd5a2a716a..f0fe00c247 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1307,7 +1307,7 @@ 錯誤 10614: 輸入金額太小或太大,請重新輸入。 錯誤 10614: 金額超出最大下單金額 %1$s,請重新輸入。試試專業模式?不受金額和幣種限制。 錯誤10615:目前不支援此交易對,請嘗試切換到其他代幣。 - 錯誤 %1$d:部位規模太小。 + 錯誤 %1$s: 倉位規模太小,請調整後重試。 錯誤 10651:當前已有持倉 錯誤10652:目前市場仍在處理其他訂單,請稍後重試。 錯誤 10655:部位規模超過目前槓桿檔位限制,請降低金額或槓桿。 @@ -2677,10 +2677,13 @@ 加倉會放大收益和風險 保證金不足時,優先新增保證金 總保證金 - 可減少保證金:%1$s 保證金調整已提交 確認新增保證金 + 正在新增保證金 + 新增保證金失敗 確認減少保證金 + 正在減少保證金 + 減少保證金失敗 %1$s %2$s 新增保證金 %1$s %2$s 減少保證金 減少 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4d8856957..23a580068b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -492,7 +492,7 @@ ERROR 10614: The input amount is either too small or too large, please adjust the amount. ERROR 10614: The amount exceeds the maximum allowable value of %1$s. Please adjust the amount. Try placing a limit order? No restrictions on amount or token. ERROR 10615: This trading pair is currently not supported, please try switching to a different token. - ERROR %1$d: Position size is too small. + ERROR %1$s: Position size is too small. ERROR 10651: You already had a open position ERROR 10652: The current market is still processing other orders, please try again later. ERROR 10655: Position size exceeds the limit for the selected leverage. Reduce the amount or leverage. @@ -2706,10 +2706,13 @@ Increase position for more gains and losses If margin is low, add margin first Total Margin - Available margin: %1$s Margin adjustment submitted Confirm Adding Margin + Adding Margin + Failed to Add Margin Confirm Reducing Margin + Reducing Margin + Failed to Reduce Margin Add Margin to %1$s %2$s Reduce Margin for %1$s %2$s Reduce diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt index dd69ff121a..4066f8a9d8 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt @@ -95,16 +95,18 @@ class LiquidationPriceRequestTest { } @Test - fun nonServerErrorStopsAfterOneRequest() = runBlocking { + fun nonServerErrorStopsAfterOneRequestAndReportsTheReason() = runBlocking { var requestCount = 0 + var errorMessage: String? = null - val price = requestLiquidationPrice(retryDelayMillis = 0L) { + val price = requestLiquidationPrice(retryDelayMillis = 0L, onFailure = { errorMessage = it }) { requestCount += 1 - LiquidationPriceResult.Failure + liquidationPriceResult(price = null, errorCode = 400, errorMessage = "Position is closed") } assertNull(price) assertEquals(1, requestCount) + assertEquals("Position is closed", errorMessage) } @Test @@ -130,12 +132,12 @@ class LiquidationPriceRequestTest { LiquidationPriceResult.Retry, liquidationPriceResult(price = null, errorCode = 500), ) - assertSame( - LiquidationPriceResult.Failure, + assertEquals( + LiquidationPriceResult.Failure(null), liquidationPriceResult(price = null, errorCode = 400), ) - assertSame( - LiquidationPriceResult.Failure, + assertEquals( + LiquidationPriceResult.Failure(null), liquidationPriceResult(price = null, errorCode = null), ) assertEquals( diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt index ddb67ca63a..1f156faaff 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -7,6 +7,31 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class PerpsMarginAmountTest { + @Test + fun removableMarginErrorConvertsUnitsWithoutRoundingAboveTheLimit() { + assertEquals("29.65%", formatPerpsMarginLimit("6.98", BigDecimal("2.07"), true)) + assertEquals("$2.07", formatPerpsMarginLimit("6.98", BigDecimal("2.07"), false)) + assertEquals("99.99%", formatPerpsMarginLimit("100", BigDecimal("99.99999999"), true)) + assertEquals("$2.07999999", formatPerpsMarginLimit("6.98", BigDecimal("2.079999999"), false)) + assertEquals("$0.00000001", formatPerpsMarginLimit("1", BigDecimal("0.00000001"), false)) + assertEquals("0%", formatPerpsMarginLimit("6.98", BigDecimal.ZERO, true)) + assertEquals("0", formatPerpsMarginLimit("6.98", BigDecimal.ZERO, false)) + assertEquals("0%", formatPerpsMarginLimit("0", BigDecimal.ZERO, true)) + } + + @Test + fun marginPreviewUsesZeroWithoutCurrencyAndTheActualLiquidationPrice() { + listOf(null, BigDecimal.ZERO, BigDecimal("0.00"), BigDecimal("0.004")).forEach { + assertEquals("0", formatPerpsMarginAmount(it)) + } + assertEquals("$1.50", formatPerpsMarginAmount(BigDecimal("1.5"))) + assertEquals(BigDecimal("130"), marginAfterAdjustment("100", "30", true)) + assertEquals(BigDecimal("12.50"), marginLiquidationLossPercent("80000", "70000")) + assertEquals(BigDecimal("12.50"), marginLiquidationLossPercent("80000", "90000")) + assertNull(marginLiquidationLossPercent("0", "70000")) + assertNull(marginLiquidationLossPercent("80000", null)) + } + @Test fun inputKeepsWholePercentagesAndTwoDollarDecimalsWithoutExceedingTheAmount() { assertEquals("0", formatMarginAdjustmentInput(BigDecimal.ZERO, true)) diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginTokenTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginTokenTest.kt index 427bff211f..bfd105cdbb 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginTokenTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginTokenTest.kt @@ -1,11 +1,26 @@ package one.mixin.android.ui.home.web3.trade.perps +import one.mixin.android.ui.home.web3.trade.limitTradeInputDecimalPlaces import one.mixin.android.vo.safe.TokenItem +import java.math.BigDecimal import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull class PerpsMarginTokenTest { + @Test + fun marginBalanceUsesTokenUnitsRegardlessOfUsdPrice() { + val token = token("margin", "10.12345678") + for (price in listOf("0.99", "1", "1.01", "0", "")) { + val balance = perpsMarginTokenBalance(token.copy(priceUsd = price))!! + assertEquals(BigDecimal("10.12345678"), balance) + assertEquals("10.12345678", limitTradeInputDecimalPlaces(balance.stripTrailingZeros().toPlainString())) + } + assertEquals(BigDecimal.ZERO, perpsMarginTokenBalance(token.copy(balance = "0"))) + assertNull(perpsMarginTokenBalance(token.copy(balance = "invalid"))) + assertNull(perpsMarginTokenBalance(null)) + } + @Test fun usesAcceptedAssetIdsAndBreaksBalanceTiesInApiOrder() { val tokens = listOf( From 83b0f37840407306584efd4a19db8c875dbc3f7b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 18 Sep 2026 15:16:01 +0800 Subject: [PATCH 14/29] fix(perps): refine margin sheet layout --- .../PerpsConfirmBottomSheetDialogFragment.kt | 26 +- .../PerpsMarginBottomSheetDialogFragment.kt | 249 +++++++++--------- 2 files changed, 143 insertions(+), 132 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt index cea9531d52..1b890bccfb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt @@ -436,20 +436,22 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } Box(modifier = Modifier.height(20.dp)) - PerpsInfoItem( - title = stringResource(R.string.Direction).uppercase(), - value = (if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)) + - if (leverage > 0) " ${leverage}x" else "" - ) - if (!isAddMargin && leverage > 0 && amount.isNotBlank()) { - Box(modifier = Modifier.height(6.dp)) - ProfitLossInfo( - amount = amount, - leverage = leverage, - isLong = isLong + if (!isAddMargin) { + PerpsInfoItem( + title = stringResource(R.string.Direction).uppercase(), + value = (if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)) + + if (leverage > 0) " ${leverage}x" else "" ) + if (leverage > 0 && amount.isNotBlank()) { + Box(modifier = Modifier.height(6.dp)) + ProfitLossInfo( + amount = amount, + leverage = leverage, + isLong = isLong + ) + } + Box(modifier = Modifier.height(20.dp)) } - Box(modifier = Modifier.height(20.dp)) if (amount.isNotBlank()) { PerpsInfoItem( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 2886c52af8..a6f87fc119 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -46,8 +47,12 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ClipOp import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -98,7 +103,6 @@ import one.mixin.android.extension.withArgs import one.mixin.android.session.Session import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment 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.TRADE_INPUT_MAX_DECIMAL_PLACES import one.mixin.android.ui.home.web3.trade.limitTradeInputDecimalPlaces import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment @@ -454,7 +458,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme amount = amountValue, percentage = percentage, margin = marginValue, - errorText = reductionLimitError, + errorText = reductionLimitError ?: errorText, enabled = !loading, onInputChanged = ::changeAmount, onToggleMode = { @@ -491,96 +495,88 @@ private fun PerpsMarginContent( inputContent: @Composable () -> Unit, ) { val actionTitle = stringResource(if (increase) R.string.Add else R.string.perps_reduce_action) - KeyboardAwareBox( - modifier = Modifier.fillMaxSize().background(MixinAppTheme.colors.background), - content = { availableHeight -> - Column(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, top = 16.dp), - ) { - val entryPrice = formatPerpsPrice(position.entryPrice, priceScale) - val currentPrice = formatPerpsPrice( - marketPrice?.takeIf { it.isNotBlank() } ?: position.markPrice ?: position.entryPrice, - priceScale, + Column( + modifier = Modifier.fillMaxSize().background(MixinAppTheme.colors.background).imePadding(), + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, top = 16.dp), + ) { + val entryPrice = formatPerpsPrice(position.entryPrice, priceScale) + val currentPrice = formatPerpsPrice( + marketPrice?.takeIf { it.isNotBlank() } ?: position.markPrice ?: position.entryPrice, + priceScale, + ) + val subtitle = stringResource(R.string.auto_close_subtitle_after_open, entryPrice, currentPrice) + Row(verticalAlignment = Alignment.CenterVertically) { + CoilImage( + model = position.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier.size(30.dp).clip(CircleShape), + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + if (increase) R.string.perps_add_margin_title else R.string.perps_reduce_margin_title, + stringResource(if (position.side.equals("long", true)) R.string.Long else R.string.Short), + position.tokenSymbol ?: position.displaySymbol.orEmpty(), + ), + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.W600, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = buildAnnotatedString { + append(subtitle) + addStyle(SpanStyle(color = MixinAppTheme.colors.textRemarks), 0, subtitle.length) + listOf(entryPrice, currentPrice).forEach { value -> + val start = subtitle.indexOf(value) + if (start >= 0) addStyle(SpanStyle(color = MixinAppTheme.colors.textAssist), start, start + value.length) + } + }, + fontSize = 12.sp, + lineHeight = 16.sp, ) - val subtitle = stringResource(R.string.auto_close_subtitle_after_open, entryPrice, currentPrice) - Row(verticalAlignment = Alignment.CenterVertically) { - CoilImage( - model = position.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier.size(30.dp).clip(CircleShape), - ) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource( - if (increase) R.string.perps_add_margin_title else R.string.perps_reduce_margin_title, - stringResource(if (position.side.equals("long", true)) R.string.Long else R.string.Short), - position.tokenSymbol ?: position.displaySymbol.orEmpty(), - ), - color = MixinAppTheme.colors.textPrimary, - fontSize = 16.sp, - lineHeight = 20.sp, - fontWeight = FontWeight.W600, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = buildAnnotatedString { - append(subtitle) - addStyle(SpanStyle(color = MixinAppTheme.colors.textRemarks), 0, subtitle.length) - listOf(entryPrice, currentPrice).forEach { value -> - val start = subtitle.indexOf(value) - if (start >= 0) addStyle(SpanStyle(color = MixinAppTheme.colors.textAssist), start, start + value.length) - } - }, - fontSize = 12.sp, - lineHeight = 16.sp, - ) - } - IconButton(onClick = onCancel, enabled = !loading) { - Icon( - painterResource(R.drawable.ic_circle_close), - stringResource(R.string.close), - tint = Color.Unspecified, - modifier = Modifier.size(26.dp), - ) - } - } - Spacer(Modifier.height(16.dp)) - inputContent() - Spacer(Modifier.height(18.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { - val currentMarginText = position.margin?.toBigDecimalOrNull()?.let { formatPerpsMarginAmount(it) } ?: "-" - PerpsAddInfoRow( - title = stringResource(R.string.Margin), - value = if (totalMargin != null) "$currentMarginText → ${formatPerpsMarginAmount(totalMargin)}" else currentMarginText, - ) - Spacer(Modifier.height(16.dp)) - PerpsAddInfoRow( - title = stringResource(R.string.Liquidation_Price), - value = listOfNotNull( - position.liquidationPrice?.takeIf { it.isNotBlank() }?.let { formatPerpsPrice(it, priceScale) } ?: "-", - estimatedLiquidationPrice?.let { formatPerpsPrice(it, priceScale) }, - ).joinToString(" → "), - isLoading = isLiquidationLoading, - onTipClick = { onGuide(PerpetualGuideBottomSheetDialogFragment.TAB_LIQUIDATION) }, - ) - } - Spacer(Modifier.height(24.dp)) } - if (availableHeight == null) { - PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = onCancel, onSubmit = onSubmit) + IconButton(onClick = onCancel, enabled = !loading) { + Icon( + painterResource(R.drawable.ic_circle_close), + stringResource(R.string.close), + tint = Color.Unspecified, + modifier = Modifier.size(26.dp), + ) } } - }, - floating = { - PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = onCancel, onSubmit = onSubmit) - }, - ) + Spacer(Modifier.height(16.dp)) + inputContent() + Spacer(Modifier.height(18.dp)) + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + val currentMarginText = position.margin?.toBigDecimalOrNull()?.let { formatPerpsMarginAmount(it) } ?: "-" + PerpsAddInfoRow( + title = stringResource(R.string.Margin), + value = if (totalMargin != null) "$currentMarginText → ${formatPerpsMarginAmount(totalMargin)}" else currentMarginText, + ) + Spacer(Modifier.height(16.dp)) + PerpsAddInfoRow( + title = stringResource(R.string.Liquidation_Price), + value = listOfNotNull( + position.liquidationPrice?.takeIf { it.isNotBlank() }?.let { formatPerpsPrice(it, priceScale) } ?: "-", + estimatedLiquidationPrice?.let { formatPerpsPrice(it, priceScale) }, + ).joinToString(" → "), + isLoading = isLiquidationLoading, + onTipClick = { onGuide(PerpetualGuideBottomSheetDialogFragment.TAB_LIQUIDATION) }, + ) + } + Spacer(Modifier.height(8.dp)) + } + PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = onCancel, onSubmit = onSubmit) + } } @Composable @@ -594,16 +590,18 @@ private fun PerpsMarginActions( onSubmit: () -> Unit, ) { Column(modifier = Modifier.fillMaxWidth().background(MixinAppTheme.colors.background)) { - Text( - text = errorText.orEmpty(), - color = MixinAppTheme.colors.walletRed, - fontSize = 14.sp, - textAlign = TextAlign.Center, - minLines = 2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), - ) + if (increase) { + Text( + text = errorText.orEmpty(), + color = MixinAppTheme.colors.walletRed, + fontSize = 14.sp, + textAlign = TextAlign.Center, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), + ) + } Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 12.dp, bottom = 20.dp), @@ -669,7 +667,7 @@ private fun PerpsReduceMarginInput( inputValue <= BigDecimal.ZERO -> MixinAppTheme.colors.textRemarks else -> MixinAppTheme.colors.textPrimary } - val inputTextStyle = TextStyle(fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor, textAlign = TextAlign.Center) + val inputTextStyle = TextStyle(fontSize = 36.sp, fontWeight = FontWeight.W500, color = inputColor, textAlign = TextAlign.Center) val textMeasurer = rememberTextMeasurer() val placeholder = if (isPercentage) "0" else "0.00" val inputWidth = with(LocalDensity.current) { @@ -699,7 +697,7 @@ private fun PerpsReduceMarginInput( Icon(painterResource(R.drawable.ic_perps_minus), stringResource(R.string.perps_reduce_action), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) } Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { - if (!isPercentage) Text(PERPS_USD_SYMBOL, fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) + if (!isPercentage) Text(PERPS_USD_SYMBOL, style = inputTextStyle) BasicTextField( value = textFieldValue, onValueChange = { value -> @@ -723,7 +721,7 @@ private fun PerpsReduceMarginInput( } }, ) - if (isPercentage) Text("%", fontSize = 48.sp, fontWeight = FontWeight.W500, color = inputColor) + if (isPercentage) Text("%", style = inputTextStyle) } IconButton( onClick = { updateInput(formatMarginAdjustmentInput((inputValue + BigDecimal.ONE).min(maximum), isPercentage)) }, @@ -740,6 +738,7 @@ private fun PerpsReduceMarginInput( text = if (isPercentage) formatPerpsMarginAmount(amount) else "${percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString() ?: "0"}%", color = MixinAppTheme.colors.textAssist, fontSize = 16.sp, + lineHeight = 20.sp, ) Spacer(Modifier.width(4.dp)) Icon( @@ -749,17 +748,21 @@ private fun PerpsReduceMarginInput( modifier = Modifier.size(16.dp), ) } - Text( - text = errorText.orEmpty(), - color = MixinAppTheme.colors.walletRed, - fontSize = 12.sp, - textAlign = TextAlign.Center, - minLines = 2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) + Box( + modifier = Modifier.fillMaxWidth().height(with(LocalDensity.current) { 32.sp.toDp() }), + contentAlignment = Alignment.Center, + ) { + Text( + text = errorText.orEmpty(), + color = MixinAppTheme.colors.walletRed, + fontSize = 12.sp, + lineHeight = 16.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(8.dp)) Slider( value = sliderPercentage, onValueChange = { selectPercentage(BigDecimal(snapMarginReductionPercentage(it))) }, @@ -767,16 +770,21 @@ private fun PerpsReduceMarginInput( valueRange = 0f..100f, steps = 99, modifier = Modifier.fillMaxWidth().height(32.dp).drawWithContent { + drawContent() val trackInset = 10.dp.toPx() - for (value in 0..100 step 25) { - val fraction = if (layoutDirection == LayoutDirection.Rtl) 1f - value / 100f else value / 100f - drawCircle( - color = if (value <= sliderPercentage) activeMarkerColor else inactiveMarkerColor, - radius = 3.dp.toPx(), - center = Offset(trackInset + (size.width - 2 * trackInset) * fraction, size.height / 2), - ) + val thumbFraction = if (layoutDirection == LayoutDirection.Rtl) 1f - sliderPercentage / 100f else sliderPercentage / 100f + val thumbCenter = Offset(trackInset + (size.width - 2 * trackInset) * thumbFraction, size.height / 2) + val thumbPath = Path().apply { addOval(Rect(thumbCenter, trackInset)) } + clipPath(thumbPath, ClipOp.Difference) { + for (value in 0..100 step 25) { + val fraction = if (layoutDirection == LayoutDirection.Rtl) 1f - value / 100f else value / 100f + drawCircle( + color = if (value <= sliderPercentage) activeMarkerColor else inactiveMarkerColor, + radius = 3.dp.toPx(), + center = Offset(trackInset + (size.width - 2 * trackInset) * fraction, size.height / 2), + ) + } } - drawContent() }, colors = SliderDefaults.colors( thumbColor = MixinAppTheme.colors.accent, @@ -792,6 +800,7 @@ private fun PerpsReduceMarginInput( text = if (isPercentage) "$value%" else formatPerpsMarginAmount(reduceMarginAmount(margin?.toPlainString(), value.toString(), true)), color = MixinAppTheme.colors.textAssist, fontSize = 10.sp, + lineHeight = 14.sp, textAlign = when (value) { 0 -> TextAlign.Start; 100 -> TextAlign.End; else -> TextAlign.Center }, modifier = Modifier.clickable(enabled = enabled && margin != null && margin > BigDecimal.ZERO) { selectPercentage(BigDecimal(value)) From dbe5c617423dd3f9fb32983296dc4730c11a0cfb Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 18 Sep 2026 15:29:48 +0800 Subject: [PATCH 15/29] fix(perps): show side and symbol in activity subtitles --- .../android/ui/home/web3/trade/perps/OpenedOrderItem.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt index 7e03adafe5..1494076504 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt @@ -130,7 +130,13 @@ fun OpenedOrderItem( Spacer(modifier = Modifier.height(4.dp)) Text( - text = if (isMargin) order.displaySymbol ?: order.tokenSymbol.orEmpty() else "$quantity ${order.tokenSymbol ?: ""}", + text = if (isIncrease || isMargin) { + val side = stringResource(if (isLong) R.string.Long else R.string.Short) + val symbol = order.tokenSymbol?.takeIf { it.isNotBlank() } ?: order.displaySymbol.orEmpty() + "$side $symbol".trimEnd() + } else { + "$quantity ${order.tokenSymbol.orEmpty()}" + }, fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, maxLines = 1, From f8eb970ba7f746c87262fa146bb797519f0aaf06 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 18 Sep 2026 16:13:43 +0800 Subject: [PATCH 16/29] fix(perps): align order detail labels and fields --- .../web3/trade/perps/PositionDetailPage.kt | 64 ++++++++++++------- app/src/main/res/values-es/strings.xml | 4 ++ app/src/main/res/values-in/strings.xml | 4 ++ app/src/main/res/values-ja/strings.xml | 4 ++ app/src/main/res/values-ko/strings.xml | 4 ++ app/src/main/res/values-ms/strings.xml | 4 ++ app/src/main/res/values-ru/strings.xml | 4 ++ app/src/main/res/values-zh-rCN/strings.xml | 4 ++ app/src/main/res/values-zh-rTW/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 10 files changed, 76 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt index 54d2dbafe5..55d46a8ac1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt @@ -598,7 +598,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.PnL).uppercase(), + label = stringResource(R.string.perps_realized_pnl).uppercase(), value = "${formatSignedFiat(pnl)} (${formatSignedPercent(roe)})", valueColor = pnlColor, ) @@ -638,7 +638,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.Close_Time).uppercase(), + label = stringResource(R.string.Date).uppercase(), value = formatDate(closeOrder.updatedAt) ) } @@ -689,12 +689,14 @@ fun OpenedOrderDetailPage( val isMargin = openedOrder.orderType == PerpsOrder.TYPE_INCREASE_MARGIN || openedOrder.orderType == PerpsOrder.TYPE_DECREASE_MARGIN val isFailed = openedOrder.status == PerpsOrder.STATUS_REJECTED val title = when { - isMargin -> stringResource(if (openedOrder.orderType == PerpsOrder.TYPE_INCREASE_MARGIN) R.string.perps_add_margin else R.string.perps_reduce_margin) + - if (isFailed) " · ${stringResource(R.string.Failed)}" else "" + isMargin && isFailed -> + stringResource(if (openedOrder.orderType == PerpsOrder.TYPE_INCREASE_MARGIN) R.string.perps_adding_margin_failed else R.string.perps_reducing_margin_failed) + isMargin -> + stringResource(if (openedOrder.orderType == PerpsOrder.TYPE_INCREASE_MARGIN) R.string.perps_added_margin else R.string.perps_reduced_margin) isIncrease && isFailed -> stringResource(if (isLong) R.string.Added_Long_Failed else R.string.Added_Short_Failed) isIncrease -> - stringResource(if (isLong) R.string.Added_Long else R.string.Added_Short) + stringResource(R.string.perps_added_position) isFailed -> stringResource(if (isLong) R.string.Opened_Long_Failed else R.string.Opened_Short_Failed) else -> @@ -711,6 +713,14 @@ fun OpenedOrderDetailPage( val quantity = openedOrder.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val absQuantity = quantity.abs() val leverage = openedOrder.leverage + val adjustmentSign = when (openedOrder.orderType) { + PerpsOrder.TYPE_INCREASE, PerpsOrder.TYPE_INCREASE_MARGIN -> "+" + PerpsOrder.TYPE_DECREASE_MARGIN -> "-" + else -> "" + } + val amountText = openedOrder.payAmount.toBigDecimalOrNull()?.let { + "$adjustmentSign${formatPerpsUsdDecimal(it.abs())}" + } PageScaffold( title = title, @@ -759,11 +769,15 @@ fun OpenedOrderDetailPage( verticalAlignment = Alignment.Bottom, ) { Text( - text = if (isMargin) openedOrder.displaySymbol ?: openedOrder.tokenSymbol.orEmpty() else absQuantity.stripTrailingZeros().toPlainString(), + text = if (isMargin) amountText ?: "-" else "$adjustmentSign${formatPerpsQuantity(absQuantity)}", fontSize = 34.sp, fontWeight = FontWeight.W500, fontFamily = FontFamily(Font(R.font.mixin_font)), - color = MixinAppTheme.colors.textPrimary, + color = when { + !isMargin || leverageDimmed || amountText == null -> MixinAppTheme.colors.textPrimary + openedOrder.orderType == PerpsOrder.TYPE_INCREASE_MARGIN -> risingColor + else -> fallingColor + }, ) val symbol = openedOrder.tokenSymbol?.takeIf { !isMargin && it.isNotBlank() } if (symbol != null) { @@ -845,28 +859,30 @@ fun OpenedOrderDetailPage( Spacer(modifier = Modifier.height(20.dp)) if (!isFailed) { - PositionDetailItem( - label = stringResource(R.string.Entry_Price).uppercase(), - value = formatPerpsPrice(openedOrder.entryPrice, openedOrder.priceScale) - ) - - val fee = openedOrder.feeAmount.toBigDecimalOrNull()?.abs() ?: BigDecimal.ZERO - if (fee.signum() != 0) { - Spacer(modifier = Modifier.height(20.dp)) - + if (!isMargin) { PositionDetailItem( - label = stringResource(R.string.Fee).uppercase(), - value = formatPerpsSignedExactUsdDecimal(fee.negate()), - onTipClick = onFeeTipClick, + label = stringResource(R.string.Entry_Price).uppercase(), + value = formatPerpsPrice(openedOrder.entryPrice, openedOrder.priceScale) ) - } - Spacer(modifier = Modifier.height(20.dp)) + val fee = openedOrder.feeAmount.toBigDecimalOrNull()?.abs() ?: BigDecimal.ZERO + if (fee.signum() != 0) { + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Fee).uppercase(), + value = formatPerpsSignedExactUsdDecimal(fee.negate()), + onTipClick = onFeeTipClick, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + } - openedOrder.payAmount.toBigDecimalOrNull()?.let { amountValue -> + amountText?.let { amount -> PositionDetailItem( label = stringResource(R.string.Amount).uppercase(), - value = formatPerpsUsdDecimal(amountValue) + value = amount ) Spacer(modifier = Modifier.height(20.dp)) } @@ -881,7 +897,7 @@ fun OpenedOrderDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(if (isMargin) R.string.Time else R.string.Open_Time).uppercase(), + label = stringResource(R.string.Date).uppercase(), value = formatDate(openedOrder.createdAt) ) } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c638dfbb76..4c413b0592 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1211,4 +1211,8 @@ Añadir posición a %1$s %2$s Reducir posición para %1$s %2$s Máximo reducible %1$s + Posición aumentada + Margen añadido + Margen reducido + PnL realizado diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 588be2c4f4..d14d6b2173 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -769,4 +769,8 @@ Tambah Posisi ke %1$s %2$s Kurangi Posisi untuk %1$s %2$s Maksimal yang dapat dikurangi %1$s + Posisi Ditambahkan + Margin Ditambahkan + Margin Dikurangi + PnL Terealisasi diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 4599cd8996..6c707342b1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1157,4 +1157,8 @@ %1$s %2$s にポジションを追加 %1$s %2$s のポジションを減らす 最大減少量 %1$s + ポジション追加 + 証拠金追加 + 証拠金減額 + 実現損益 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fe641f51bf..6bc8f9a769 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2692,4 +2692,8 @@ %1$s %2$s에 포지션 추가 %1$s %2$s의 포지션 축소 최대 축소 가능 %1$s + 포지션 추가 + 증거금 추가 + 증거금 감소 + 실현 손익 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 78987168c0..e1a9cdcaaa 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -834,4 +834,8 @@ Tambah Posisi pada %1$s %2$s Kurangkan Posisi untuk %1$s %2$s Maksimum boleh dikurangkan %1$s + Posisi Ditambah + Margin Ditambah + Margin Dikurangkan + PnL Direalisasikan diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d57e5b611a..e65eb28933 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1152,4 +1152,8 @@ Увеличить позицию в %1$s %2$s Уменьшить позицию для %1$s %2$s Максимально можно уменьшить на %1$s + Позиция увеличена + Маржа добавлена + Маржа уменьшена + Реализованная прибыль/убыток diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 63ab87dcd6..2a97d59c6c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2642,4 +2642,8 @@ %1$s %2$s 增加仓位 %1$s %2$s 减少仓位 最多减少 %1$s + 已加仓 + 已增加保证金 + 已减少保证金 + 已实现盈亏 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f0fe00c247..412eca4a50 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2693,4 +2693,8 @@ %1$s %2$s 增加倉位 %1$s %2$s 減少倉位 最多減少 %1$s + 已加倉 + 已增加保證金 + 已減少保證金 + 已實現盈虧 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 052d93c36e..b6702fd7e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2724,4 +2724,8 @@ Add Position to %1$s %2$s Reduce Position for %1$s %2$s Max removable %1$s + Added Position + Added Margin + Reduced Margin + Realized PnL From 643448db19326c3cc465d1115c629e64c3da49ad Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 18 Sep 2026 20:43:42 +0800 Subject: [PATCH 17/29] fix(perps): enlarge margin amount switch icon --- .../web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index b0acb6b93b..b357e96c31 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -746,7 +746,7 @@ private fun PerpsReduceMarginInput( painterResource(R.drawable.ic_switch), stringResource(if (isPercentage) R.string.Amount else R.string.perps_percentage), tint = MixinAppTheme.colors.textRemarks, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(30.dp), ) } Box( From f00bfd548e17d2490172fb600cd14ce4a3fe7063 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 18 Sep 2026 21:39:58 +0800 Subject: [PATCH 18/29] fix(wallet): keep review button blue while loading --- .../main/java/one/mixin/android/ui/wallet/InputFragment.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt index 9338519c3a..dbf017e50f 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt @@ -3,6 +3,7 @@ package one.mixin.android.ui.wallet import android.annotation.SuppressLint import android.content.ClipboardManager import android.content.Context +import android.content.res.ColorStateList import android.graphics.Paint import android.os.Bundle import android.util.TypedValue @@ -1646,6 +1647,11 @@ class InputFragment : BaseFragment(R.layout.fragment_input), OnReceiveSelectionC } binding.continueTv.setText(R.string.Review) binding.continueVa.displayedChild = if (cashQuoteReviewing) 1 else 0 + binding.continueVa.backgroundTintList = if (cashQuoteReviewing) { + ColorStateList.valueOf(requireContext().getColor(R.color.colorAccent)) + } else { + null + } if (cashQuoteReviewing) { binding.continueVa.isEnabled = false } From a80204e1f15270524920ebb0de5ebf1798a5f14b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sun, 20 Sep 2026 17:12:20 +0800 Subject: [PATCH 19/29] fix(analytics): unify perps adjustment source --- .../web3/trade/perps/PerpsMarketDetailPage.kt | 4 ++-- .../android/util/analytics/AnalyticsTracker.kt | 9 ++++----- .../util/analytics/AnalyticsRulesTest.kt | 18 +++++++++--------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index f55e4312e5..3447825444 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -157,7 +157,7 @@ fun PerpsMarketDetailPage( PerpsMarginBottomSheetDialogFragment.newInstance( position, increase = action == PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_MARGIN, - source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU, + source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_ADJUST, ).show(activity.supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) } PerpsAdjustBottomSheetDialogFragment.ACTION_ADD_POSITION -> { @@ -166,7 +166,7 @@ fun PerpsMarketDetailPage( viewModel = viewModel, position = position, market = market, - source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_ADD, + source = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_ADJUST, onDismiss = { isAddingProcessing = false }, ) } diff --git a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt index 9f4423fa2d..8c8a99b53e 100644 --- a/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt +++ b/app/src/main/java/one/mixin/android/util/analytics/AnalyticsTracker.kt @@ -637,8 +637,7 @@ object AnalyticsTracker { const val PERPS_OPEN_POSITION_SIZE = "perps_open_position_size" const val SPOT_MARKET_DETAIL = "spot_market_detail" const val PERPS_MARKET_DETAIL = "perps_market_detail" - const val PERPS_MARKET_DETAIL_ADD = "perps_market_detail_add" - const val PERPS_MARKET_DETAIL_BOTTOM_MENU = "perps_market_detail_bottom_menu" + const val PERPS_MARKET_DETAIL_ADJUST = "perps_market_detail_adjust" const val URL = "url" const val PERPS_ALL_POSITIONS = "perps_all_positions" const val PERPS_ACTIVITY_DETAIL = "perps_activity_detail" @@ -756,7 +755,7 @@ object AnalyticsTracker { fun trackPerpsAddPositionStart(source: String) { logEvent("trade_perps_add_position_start") { - putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_ADD)) + putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_ADJUST)) } } @@ -789,7 +788,7 @@ object AnalyticsTracker { fun trackPerpsAddMarginStart(source: String) { logEvent("trade_perps_add_margin_start") { - putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU)) + putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_ADJUST)) } } @@ -822,7 +821,7 @@ object AnalyticsTracker { fun trackPerpsReduceMarginStart(source: String) { logEvent("trade_perps_reduce_margin_start") { - putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_BOTTOM_MENU)) + putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_ADJUST)) } } diff --git a/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt b/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt index a39485c235..5841c6225d 100644 --- a/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt +++ b/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt @@ -7,15 +7,15 @@ import org.junit.Test class AnalyticsRulesTest { @Test fun perpsAdjustmentsPreserveLinkSourcesAndUseTheActionEntryForOtherSources() { - for (entry in listOf("perps_market_detail_add", "perps_market_detail_bottom_menu")) { - assertEquals("app_card", AnalyticsRules.perpsAdjustmentSource("app_card", entry)) - assertEquals("url", AnalyticsRules.perpsAdjustmentSource("schema", entry)) - assertEquals("url", AnalyticsRules.perpsAdjustmentSource("scheme", entry)) - assertEquals("url", AnalyticsRules.perpsAdjustmentSource("url", entry)) - assertEquals(entry, AnalyticsRules.perpsAdjustmentSource(entry, entry)) - assertEquals(entry, AnalyticsRules.perpsAdjustmentSource("perps_market_detail", entry)) - assertEquals(entry, AnalyticsRules.perpsAdjustmentSource("", entry)) - } + val entry = AnalyticsTracker.PerpsSource.PERPS_MARKET_DETAIL_ADJUST + assertEquals("perps_market_detail_adjust", entry) + assertEquals("app_card", AnalyticsRules.perpsAdjustmentSource("app_card", entry)) + assertEquals("url", AnalyticsRules.perpsAdjustmentSource("schema", entry)) + assertEquals("url", AnalyticsRules.perpsAdjustmentSource("scheme", entry)) + assertEquals("url", AnalyticsRules.perpsAdjustmentSource("url", entry)) + assertEquals(entry, AnalyticsRules.perpsAdjustmentSource(entry, entry)) + assertEquals(entry, AnalyticsRules.perpsAdjustmentSource("perps_market_detail", entry)) + assertEquals(entry, AnalyticsRules.perpsAdjustmentSource("", entry)) } @Test From a2dda518b89a157e2f1f09a47226e562632433bb Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 21 Sep 2026 09:05:48 +0800 Subject: [PATCH 20/29] fix(perps): align margin controls and fill decimal limits --- .../web3/trade/perps/PerpsMarginAmount.kt | 9 ++++- .../PerpsMarginBottomSheetDialogFragment.kt | 38 ++++++++++++------- .../web3/trade/perps/PerpsMarginAmountTest.kt | 30 +++++++++++++-- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt index 14e3e8f175..c94a5ca081 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt @@ -19,7 +19,7 @@ internal fun formatPerpsMarginLimit(currentMargin: String?, availableMargin: Big val percentage = marginReductionPercentage(currentMargin, availableMargin) ?: BigDecimal.ZERO return "${percentage.setScale(2, RoundingMode.DOWN).numberFormat8()}%" } - val amount = availableMargin.setScale(TRADE_INPUT_MAX_DECIMAL_PLACES, RoundingMode.DOWN) + val amount = availableMargin.setScale(2, RoundingMode.DOWN) return if (amount.signum() == 0) "0" else "$PERPS_USD_SYMBOL${amount.numberFormat8()}" } @@ -36,7 +36,7 @@ internal fun snapMarginReductionPercentage(value: Float): Int { } internal fun formatMarginAdjustmentInput(value: BigDecimal, isPercentage: Boolean): String = - value.setScale(if (isPercentage) 0 else 2, RoundingMode.DOWN).let { if (it.signum() == 0) "0" else it.toPlainString() } + value.setScale(if (isPercentage) 0 else 2, RoundingMode.DOWN).stripTrailingZeros().toPlainString() internal fun maximumMarginReductionInput(currentMargin: String?, maximumReduction: BigDecimal, isPercentage: Boolean): String = formatMarginAdjustmentInput( @@ -44,6 +44,11 @@ internal fun maximumMarginReductionInput(currentMargin: String?, maximumReductio isPercentage, ) +internal fun canFillMarginReductionAsPercentage(currentMargin: String?, maximumReduction: BigDecimal): Boolean { + val percentage = marginReductionPercentage(currentMargin, maximumReduction) ?: return false + return percentage.stripTrailingZeros().scale() <= 0 +} + internal fun marginAdjustmentAmount(amount: String): BigDecimal? { if (amount.length > 40 || amount.any { it !in '0'..'9' && it != '.' } || amount.count { it == '.' } > 1 || amount.substringAfter('.', "").length > TRADE_INPUT_MAX_DECIMAL_PLACES) return null diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index b357e96c31..87ff95e95c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -459,7 +460,13 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme enabled = !loading, onInputChanged = ::changeAmount, onApplyReductionLimit = maximumReduction?.takeIf { reductionLimitError != null }?.let { maximum -> - { changeAmount(maximumMarginReductionInput(currentPosition.margin, maximum, reduceByPercent)) } + { + if (reduceByPercent && !canFillMarginReductionAsPercentage(currentPosition.margin, maximum)) { + reduceByPercent = false + preferences.putBoolean(PREF_REDUCE_BY_PERCENT, false) + } + changeAmount(maximumMarginReductionInput(currentPosition.margin, maximum, reduceByPercent)) + } }, onToggleMode = { amount = amountValue?.let { formatMarginAdjustmentInput(it, false) }.orEmpty() @@ -687,10 +694,10 @@ private fun PerpsReduceMarginInput( .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) - .padding(16.dp), + .padding(start = 16.dp, top = 40.dp, end = 16.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { IconButton( onClick = { updateInput(formatMarginAdjustmentInput((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO), isPercentage)) }, enabled = enabled && inputValue > BigDecimal.ZERO, @@ -731,8 +738,9 @@ private fun PerpsReduceMarginInput( Icon(painterResource(R.drawable.ic_perps_add), stringResource(R.string.Add), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) } } + Spacer(Modifier.height(6.dp)) Row( - modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(enabled = enabled, onClick = onToggleMode).padding(horizontal = 12.dp, vertical = 12.dp), + modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(enabled = enabled, onClick = onToggleMode).padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -749,8 +757,9 @@ private fun PerpsReduceMarginInput( modifier = Modifier.size(30.dp), ) } + Spacer(Modifier.height(12.dp)) Box( - modifier = Modifier.fillMaxWidth().height(with(LocalDensity.current) { 32.sp.toDp() }) + modifier = Modifier.fillMaxWidth().heightIn(min = with(LocalDensity.current) { 16.sp.toDp() }) .then(if (onApplyReductionLimit != null) Modifier.clickable(enabled = enabled, onClick = onApplyReductionLimit) else Modifier), contentAlignment = Alignment.Center, ) { @@ -764,7 +773,7 @@ private fun PerpsReduceMarginInput( overflow = TextOverflow.Ellipsis, ) } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(28.dp)) Slider( value = sliderPercentage, onValueChange = { selectPercentage(BigDecimal(snapMarginReductionPercentage(it))) }, @@ -800,28 +809,29 @@ private fun PerpsReduceMarginInput( listOf(0, 25, 50, 75, 100).forEach { value -> Text( text = if (isPercentage) "$value%" else formatPerpsMarginAmount(reduceMarginAmount(margin?.toPlainString(), value.toString(), true)), - color = MixinAppTheme.colors.textAssist, - fontSize = 10.sp, - lineHeight = 14.sp, - textAlign = when (value) { 0 -> TextAlign.Start; 100 -> TextAlign.End; else -> TextAlign.Center }, + color = MixinAppTheme.colors.textPrimary, + fontSize = 12.sp, + lineHeight = 16.sp, + textAlign = TextAlign.Center, modifier = Modifier.clickable(enabled = enabled && margin != null && margin > BigDecimal.ZERO) { selectPercentage(BigDecimal(value)) - }.padding(vertical = 12.dp), + }.padding(top = 4.dp), ) } }) { measurables, constraints -> val labels = measurables.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } val width = constraints.maxWidth val trackInset = 10.dp.roundToPx() + val trackEdge = trackInset - 3.dp.roundToPx() layout(width, labels.maxOf { it.height }) { labels.forEachIndexed { index, label -> val center = trackInset + (width - 2 * trackInset) * index / (labels.size - 1) val x = when (index) { - 0 -> 0 - labels.lastIndex -> width - label.width + 0 -> trackEdge + labels.lastIndex -> width - trackEdge - label.width else -> center - label.width / 2 } - label.placeRelative(x.coerceIn(0, width - label.width), 0) + label.placeRelative(x, 0) } } } diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt index 7f3b99431e..7876dbc001 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -4,14 +4,31 @@ import com.google.gson.JsonParser import java.math.BigDecimal import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue class PerpsMarginAmountTest { + @Test + fun fractionalPercentageLimitCanBeFilledAsDollars() { + val maximum = BigDecimal("0.056") + assertEquals("0.56%", formatPerpsMarginLimit("9.97", maximum, true)) + val isPercentage = canFillMarginReductionAsPercentage("9.97", maximum) + assertFalse(isPercentage) + val input = maximumMarginReductionInput("9.97", maximum, isPercentage) + assertEquals("0.05", input) + val reduction = requireNotNull(reduceMarginAmount("9.97", input, isPercentage)) + assertTrue(reduction <= maximum) + assertEquals(BigDecimal("9.92"), marginAfterAdjustment("9.97", input, false, maximum)) + assertFalse(canFillMarginReductionAsPercentage("6.98", BigDecimal("2.07"))) + assertTrue(canFillMarginReductionAsPercentage("100", BigDecimal("50.00"))) + assertTrue(canFillMarginReductionAsPercentage("100", BigDecimal.ZERO)) + } + @Test fun fillsMaximumRemovalInTheCurrentUnitWithoutExceedingTheLimit() { assertEquals("50", maximumMarginReductionInput("100", BigDecimal("50"), true)) - assertEquals("50.00", maximumMarginReductionInput("100", BigDecimal("50"), false)) + assertEquals("50", maximumMarginReductionInput("100", BigDecimal("50"), false)) assertEquals("29", maximumMarginReductionInput("6.98", BigDecimal("2.07"), true)) assertEquals("2.07", maximumMarginReductionInput("6.98", BigDecimal("2.07999999"), false)) for (isPercentage in listOf(true, false)) { @@ -27,8 +44,10 @@ class PerpsMarginAmountTest { assertEquals("29.65%", formatPerpsMarginLimit("6.98", BigDecimal("2.07"), true)) assertEquals("$2.07", formatPerpsMarginLimit("6.98", BigDecimal("2.07"), false)) assertEquals("99.99%", formatPerpsMarginLimit("100", BigDecimal("99.99999999"), true)) - assertEquals("$2.07999999", formatPerpsMarginLimit("6.98", BigDecimal("2.079999999"), false)) - assertEquals("$0.00000001", formatPerpsMarginLimit("1", BigDecimal("0.00000001"), false)) + assertEquals("$2.07", formatPerpsMarginLimit("6.98", BigDecimal("2.079999999"), false)) + assertEquals("$1.12", formatPerpsMarginLimit("10.93", BigDecimal("1.12139433"), false)) + assertEquals("1.12", maximumMarginReductionInput("10.93", BigDecimal("1.12139433"), false)) + assertEquals("0", formatPerpsMarginLimit("1", BigDecimal("0.00000001"), false)) assertEquals("0%", formatPerpsMarginLimit("6.98", BigDecimal.ZERO, true)) assertEquals("0", formatPerpsMarginLimit("6.98", BigDecimal.ZERO, false)) assertEquals("0%", formatPerpsMarginLimit("0", BigDecimal.ZERO, true)) @@ -53,7 +72,10 @@ class PerpsMarginAmountTest { assertEquals("100", formatMarginAdjustmentInput(BigDecimal("100"), true)) assertEquals("0", formatMarginAdjustmentInput(BigDecimal.ZERO, false)) assertEquals("0", formatMarginAdjustmentInput(BigDecimal("0.009"), false)) - assertEquals("1.50", formatMarginAdjustmentInput(BigDecimal("1.5"), false)) + assertEquals("1.5", formatMarginAdjustmentInput(BigDecimal("1.5"), false)) + assertEquals("2", formatMarginAdjustmentInput(BigDecimal.ONE + BigDecimal.ONE, false)) + assertEquals("1", formatMarginAdjustmentInput(BigDecimal("2.00") - BigDecimal.ONE, false)) + assertEquals("2.5", formatMarginAdjustmentInput(BigDecimal("1.50") + BigDecimal.ONE, false)) assertEquals("5.01", formatMarginAdjustmentInput(BigDecimal("5.01253097"), false)) assertEquals("0.99", formatMarginAdjustmentInput(BigDecimal("0.999"), false)) } From f397976bb71625e2ed9c30a01fd354627223e3d9 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 21 Sep 2026 09:19:12 +0800 Subject: [PATCH 21/29] fix(perps): sync minimum margin and position errors --- .../one/mixin/android/util/ErrorHandler.kt | 19 ++++++----- app/src/main/res/values-es/strings.xml | 5 ++- app/src/main/res/values-in/strings.xml | 5 ++- app/src/main/res/values-ja/strings.xml | 5 ++- app/src/main/res/values-ko/strings.xml | 5 ++- app/src/main/res/values-ms/strings.xml | 5 ++- app/src/main/res/values-ru/strings.xml | 5 ++- app/src/main/res/values-zh-rCN/strings.xml | 6 ++-- app/src/main/res/values-zh-rTW/strings.xml | 5 ++- app/src/main/res/values/strings.xml | 6 ++-- .../mixin/android/util/ErrorHandlerTest.kt | 34 +++++++++++++------ 11 files changed, 69 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/one/mixin/android/util/ErrorHandler.kt b/app/src/main/java/one/mixin/android/util/ErrorHandler.kt index 874f0c0779..8d0b113543 100644 --- a/app/src/main/java/one/mixin/android/util/ErrorHandler.kt +++ b/app/src/main/java/one/mixin/android/util/ErrorHandler.kt @@ -237,9 +237,9 @@ open class ErrorHandler { const val NO_AVAILABLE_QUOTE = 10615 const val SIMULATE_TRANSACTION_FAILED = 10631 const val MAX_WALLET_REACHED = 10632 - const val PERPS_ORDER_VALUE_TOO_SMALL = 10650 + const val PERPS_MARGIN_TOO_SMALL = 10650 const val PERPS_MARKET_ALREADY_HAS_ACTIVE_POSITION = 10651 - const val PERPS_ORDER_VALUE_BELOW_MINIMUM = 10654 + const val PERPS_POSITION_SIZE_TOO_SMALL = 10654 const val PERPS_POSITION_SIZE_EXCEEDS_LEVERAGE_LIMIT = 10655 const val PERPS_INVALID_LEADER_POSITION = 10656 @@ -286,7 +286,7 @@ fun Context.getMixinErrorStringByCode( code: Int, message: String, ): String { - perpsOrderValueErrorResource(code)?.let { resource -> + perpsMinimumValueErrorResource(code)?.let { resource -> // MixinResponse.errorDescription appends extra as the last JSON line. val minimum = runCatching { @@ -299,9 +299,9 @@ fun Context.getMixinErrorStringByCode( ?.toPlainString() }.getOrNull() if (minimum != null) { - return getString(R.string.error_perps_order_value_minimum, code, minimum) + return getString(requireNotNull(perpsMinimumValueErrorResource(code, hasMinimum = true)), "\$$minimum") } - return getString(resource, code) + return getString(resource) } return when (code) { @@ -481,10 +481,11 @@ fun Context.getMixinErrorStringByCode( } @StringRes -internal fun perpsOrderValueErrorResource(code: Int): Int? = +internal fun perpsMinimumValueErrorResource(code: Int, hasMinimum: Boolean = false): Int? = when (code) { - ErrorHandler.PERPS_ORDER_VALUE_TOO_SMALL, - ErrorHandler.PERPS_ORDER_VALUE_BELOW_MINIMUM, - -> R.string.error_perps_order_value_too_small + ErrorHandler.PERPS_MARGIN_TOO_SMALL -> + if (hasMinimum) R.string.error_perps_margin_too_small_value else R.string.error_perps_margin_too_small + ErrorHandler.PERPS_POSITION_SIZE_TOO_SMALL -> + if (hasMinimum) R.string.error_perps_position_size_too_small_value else R.string.error_perps_position_size_too_small else -> null } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4c413b0592..b54b26aedb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -394,7 +394,10 @@ ERROR 10006: Actualiza Mixin(%1$s) para seguir usando el servicio. No puedo encontrar una aplicación de mapas Por favor abre los permisos necesarios - ERROR %1$s: El tamaño de la posición es demasiado pequeño. + ERROR 10650: El margen es demasiado bajo + ERROR 10650: El margen debe tener un valor mínimo de %1$s + ERROR 10654: El valor de la posición es demasiado bajo + ERROR 10654: La posición debe tener un valor mínimo de %1$s ERROR 20110: Número de teléfono no válido ERROR 10003: Error al entregar SMS ERROR 20114: Código de verificación del teléfono caducado diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index d14d6b2173..3bfd6c9005 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -246,7 +246,10 @@ KESALAHAN 10006: Harap perbarui Mixin(%1$s) untuk terus menggunakan layanan. Tidak dapat menemukan aplikasi peta Harap buka izin yang diperlukan - KESALAHAN %1$s: Ukuran posisi terlalu kecil. + KESALAHAN 10650: Margin terlalu kecil + KESALAHAN 10650: Nilai margin harus minimal %1$s + KESALAHAN 10654: Nilai posisi terlalu kecil + KESALAHAN 10654: Nilai posisi harus minimal %1$s KESALAHAN 20110: Nomor telepon tidak valid KESALAHAN 10003: Gagal mengirim SMS KESALAHAN 20114: Kode verifikasi telepon sudah tidak berlaku diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 6c707342b1..877ec0a2ab 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -370,7 +370,10 @@ エラー 10006:このサービスを引き続き使用するには、Mixin(%1$s)をアップデートしてください。 地図アプリがありません 必要な権限を開いてください - エラー %1$s:ポジションサイズが小さすぎます。 + エラー 10650: マージンが少なすぎます + エラー 10650: マージンは最低 %1$s 必要です + エラー 10654: ポジション価値が小さすぎます + エラー 10654: ポジション価値は最低 %1$s 必要です エラー 20110:無効な電話番号です エラー 10003:SMSの送信に失敗しました 期限切れ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6bc8f9a769..d798c2db95 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -484,7 +484,10 @@ 오류 10614: 입력 금액이 너무 작거나 너무 큽니다. 금액을 조정하세요. 오류 10614: 금액이 %1$s의 최대 허용 값을 초과했습니다. 금액을 조정해주세요. 지정가 주문을 해보시겠어요? 금액이나 토큰에 제한이 없습니다. 오류 10615: 이 거래 쌍은 현재 지원되지 않습니다. 다른 토큰으로 전환해 보십시오. - 오류 %1$s: 포지션 크기가 너무 작습니다. + 오류 10650: 마진이 너무 적습니다 + 오류 10650: 마진은 최소 %1$s 이상이어야 합니다 + 오류 10654: 포지션 가치가 너무 작습니다 + 오류 10654: 포지션 가치는 최소 %1$s 이상이어야 합니다 오류 10651: 이미 오픈 포지션이 있습니다. 오류 10652: 현재 시장에서 아직 다른 주문을 처리 중입니다. 나중에 다시 시도하십시오. 오류 10655: 포지션 크기가 선택한 레버리지 한도를 초과했습니다. 금액 또는 레버리지를 낮추세요. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index e1a9cdcaaa..cc002904be 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -265,7 +265,10 @@ RALAT 10006: Sila kemas kini Mixin(%1$s) untuk terus menggunakan perkhidmatan ini. Tidak dapat mencari aplikasi peta Sila buka kebenaran yang diperlukan - RALAT %1$s: Saiz posisi terlalu kecil. + RALAT 10650: Margin terlalu kecil + RALAT 10650: Nilai margin mestilah sekurang-kurangnya %1$s + RALAT 10654: Nilai kedudukan terlalu kecil + RALAT 10654: Nilai kedudukan mestilah sekurang-kurangnya %1$s RALAT 20110: Nombor telefon tidak sah RALAT 10003: Gagal menghantar SMS RALAT 20114: Kod pengesahan telefon yang telah tamat tempoh diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index e65eb28933..82e383b22b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -377,7 +377,10 @@ ОШИБКА 10006: Обновите Mixin(%1$s), чтобы продолжить использование сервиса. Не могу найти приложение карты Пожалуйста, Откройте необходимые разрешения - ОШИБКА %1$s: Размер позиции слишком мал. + ОШИБКА 10650: Сумма маржи слишком мала + ОШИБКА 10650: Сумма маржи должна составлять не менее %1$s + ОШИБКА 10654: Стоимость позиции слишком мала + ОШИБКА 10654: Стоимость позиции должна составлять не менее %1$s ОШИБКА 20110: Неверный номер телефона ОШИБКА 10003: Не удалось доставить SMS ОШИБКА 20114: Срок действия кода подтверждения телефона истек diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2a97d59c6c..c43deae507 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -480,8 +480,10 @@ 错误 10614: 输入金额太小或太大,请重新输入。 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 - 错误 %1$s: 仓位规模太小,请调整后重试。 - 错误 %1$d:订单价值至少为 %2$s USD,请调整后重试。 + 错误 10650:保证金太少 + 错误 10650:保证金至少 %1$s + 错误 10654:仓位价值太少 + 错误 10654:仓位价值至少 %1$s 错误 10651:当前已有持仓 当前市场已开仓。 错误 10655:仓位规模超过当前杠杆档位限制,请降低金额或杠杆。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 412eca4a50..4cd2f46620 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1307,7 +1307,10 @@ 錯誤 10614: 輸入金額太小或太大,請重新輸入。 錯誤 10614: 金額超出最大下單金額 %1$s,請重新輸入。試試專業模式?不受金額和幣種限制。 錯誤10615:目前不支援此交易對,請嘗試切換到其他代幣。 - 錯誤 %1$s: 倉位規模太小,請調整後重試。 + 錯誤 10650:保證金太少 + 錯誤 10650:保證金至少 %1$s + 錯誤 10654:倉位價值太少 + 錯誤 10654:倉位價值至少 %1$s 錯誤 10651:當前已有持倉 錯誤10652:目前市場仍在處理其他訂單,請稍後重試。 錯誤 10655:部位規模超過目前槓桿檔位限制,請降低金額或槓桿。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6702fd7e9..bc8c51c7f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -492,8 +492,10 @@ ERROR 10614: The input amount is either too small or too large, please adjust the amount. ERROR 10614: The amount exceeds the maximum allowable value of %1$s. Please adjust the amount. Try placing a limit order? No restrictions on amount or token. ERROR 10615: This trading pair is currently not supported, please try switching to a different token. - ERROR %1$s: Position size is too small. - ERROR %1$d: Order value must be at least %2$s USD. + ERROR 10650: Margin is too small + ERROR 10650: Margin must have minimum value of %1$s + ERROR 10654: Position size is too small + ERROR 10654: Position must have minimum size of %1$s ERROR 10651: You already had a open position ERROR 10652: The current market is still processing other orders, please try again later. ERROR 10655: Position size exceeds the limit for the selected leverage. Reduce the amount or leverage. diff --git a/app/src/test/java/one/mixin/android/util/ErrorHandlerTest.kt b/app/src/test/java/one/mixin/android/util/ErrorHandlerTest.kt index 44910cf6df..0104825ade 100644 --- a/app/src/test/java/one/mixin/android/util/ErrorHandlerTest.kt +++ b/app/src/test/java/one/mixin/android/util/ErrorHandlerTest.kt @@ -20,21 +20,25 @@ import kotlin.test.assertNull @Config(application = Application::class, manifest = Config.NONE, sdk = [28]) class ErrorHandlerTest { @Test - fun mapsPerpsOrderValueBelowMinimumErrors() { - assertEquals(R.string.error_perps_order_value_too_small, perpsOrderValueErrorResource(10650)) - assertEquals(R.string.error_perps_order_value_too_small, perpsOrderValueErrorResource(10654)) - assertNull(perpsOrderValueErrorResource(10653)) + fun distinguishesPerpsMarginAndPositionMinimumErrors() { + assertEquals(R.string.error_perps_margin_too_small, perpsMinimumValueErrorResource(10650)) + assertEquals(R.string.error_perps_margin_too_small_value, perpsMinimumValueErrorResource(10650, hasMinimum = true)) + assertEquals(R.string.error_perps_position_size_too_small, perpsMinimumValueErrorResource(10654)) + assertEquals(R.string.error_perps_position_size_too_small_value, perpsMinimumValueErrorResource(10654, hasMinimum = true)) + assertNull(perpsMinimumValueErrorResource(10653)) } @Test - fun showMinimumOrderValueWhenProvided() { + fun showsMinimumValueWithCurrencyOrFallsBackToTheMatchingError() { val application = ApplicationProvider.getApplicationContext() val resources = object : Resources(application.assets, application.resources.displayMetrics, application.resources.configuration) { override fun getString(id: Int): String = when (id) { - R.string.error_perps_order_value_too_small -> "ERROR %1\$d: Position size is too small." - R.string.error_perps_order_value_minimum -> "ERROR %1\$d: Order value must be at least %2\$s USD." + R.string.error_perps_margin_too_small -> "ERROR 10650: Margin is too small" + R.string.error_perps_margin_too_small_value -> "ERROR 10650: Margin must have minimum value of %1\$s" + R.string.error_perps_position_size_too_small -> "ERROR 10654: Position size is too small" + R.string.error_perps_position_size_too_small_value -> "ERROR 10654: Position must have minimum size of %1\$s" else -> error("Unexpected string resource: $id") } @@ -52,12 +56,15 @@ class ErrorHandlerTest { for ((extra, minimum) in listOf( """{"field":"amount","min_order_value":"10","reason":"order value must be at least 10"}""" to "10", """{"min_order_value":"0.20"}""" to "0.2", + """{"min_order_value":"0.00000001"}""" to "0.00000001", """{"min_order_value":10}""" to "10", """{"min_order_value":"0"}""" to null, """{"min_order_value":"-10"}""" to null, """{"min_order_value":"invalid"}""" to null, """{"min_order_value":{}}""" to null, """{"min_order_value":null}""" to null, + """{"min_order_value":true}""" to null, + """{"min_order_value":"1e10"}""" to null, "{}" to null, "null" to null, )) { @@ -67,12 +74,17 @@ class ErrorHandlerTest { ResponseError::class.java, ) val response = MixinResponse(error) - val expected = - minimum?.let { "ERROR $code: Order value must be at least $it USD." } - ?: "ERROR $code: Position size is too small." + val expected = if (code == 10650) { + minimum?.let { "ERROR 10650: Margin must have minimum value of \$$it" } + ?: "ERROR 10650: Margin is too small" + } else { + minimum?.let { "ERROR 10654: Position must have minimum size of \$$it" } + ?: "ERROR 10654: Position size is too small" + } assertEquals(expected, context.getMixinErrorStringByCode(response.errorCode, response.errorDescription)) } } - assertEquals("ERROR 10654: Position size is too small.", context.getMixinErrorStringByCode(10654, "Invalid extra\n{broken")) + assertEquals("ERROR 10650: Margin is too small", context.getMixinErrorStringByCode(10650, "Invalid extra\n{broken")) + assertEquals("ERROR 10654: Position size is too small", context.getMixinErrorStringByCode(10654, "Invalid extra\n{broken")) } } From 20515ea13f264bceb571350679f51318806c161d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 21 Sep 2026 13:43:17 +0800 Subject: [PATCH 22/29] fix(perps): align margin input with design --- .../ui/home/web3/trade/perps/PerpsMarginAmount.kt | 4 ++-- .../perps/PerpsMarginBottomSheetDialogFragment.kt | 15 ++++++++------- .../web3/trade/perps/PerpsMarginAmountTest.kt | 8 ++++---- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt index c94a5ca081..716466117d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt @@ -12,7 +12,7 @@ import kotlin.math.roundToInt internal fun perpsMarginTokenBalance(token: TokenItem?): BigDecimal? = token?.balance?.toBigDecimalOrNull() internal fun formatPerpsMarginAmount(value: BigDecimal?): String = - if (value == null || value.setScale(2, RoundingMode.HALF_UP).signum() == 0) "0" else formatPerpsPrice(value, 2) + if (value == null || value.setScale(2, RoundingMode.HALF_UP).signum() == 0) "${PERPS_USD_SYMBOL}0" else formatPerpsPrice(value, 2) internal fun formatPerpsMarginLimit(currentMargin: String?, availableMargin: BigDecimal, isPercentage: Boolean): String { if (isPercentage) { @@ -20,7 +20,7 @@ internal fun formatPerpsMarginLimit(currentMargin: String?, availableMargin: Big return "${percentage.setScale(2, RoundingMode.DOWN).numberFormat8()}%" } val amount = availableMargin.setScale(2, RoundingMode.DOWN) - return if (amount.signum() == 0) "0" else "$PERPS_USD_SYMBOL${amount.numberFormat8()}" + return "$PERPS_USD_SYMBOL${amount.numberFormat8()}" } internal fun marginLiquidationLossPercent(entryPrice: String, liquidationPrice: String?): BigDecimal? { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 87ff95e95c..7d8d1410bd 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -671,11 +671,11 @@ private fun PerpsReduceMarginInput( val inactiveMarkerColor = MixinAppTheme.colors.textRemarks val maximum = if (isPercentage) BigDecimal(100) else margin ?: BigDecimal.ZERO val inputColor = when { - errorText != null -> MixinAppTheme.colors.walletRed + errorText != null -> MixinAppTheme.colors.marketRed inputValue <= BigDecimal.ZERO -> MixinAppTheme.colors.textRemarks else -> MixinAppTheme.colors.textPrimary } - val inputTextStyle = TextStyle(fontSize = 36.sp, fontWeight = FontWeight.W500, color = inputColor, textAlign = TextAlign.Center) + val inputTextStyle = TextStyle(fontSize = 36.sp, fontWeight = FontWeight.W600, color = inputColor, textAlign = TextAlign.Center) val textMeasurer = rememberTextMeasurer() val placeholder = "0" val inputWidth = with(LocalDensity.current) { @@ -697,14 +697,14 @@ private fun PerpsReduceMarginInput( .padding(start = 16.dp, top = 40.dp, end = 16.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(32.dp), verticalAlignment = Alignment.CenterVertically) { IconButton( onClick = { updateInput(formatMarginAdjustmentInput((inputValue - BigDecimal.ONE).max(BigDecimal.ZERO), isPercentage)) }, enabled = enabled && inputValue > BigDecimal.ZERO, ) { Icon(painterResource(R.drawable.ic_perps_minus), stringResource(R.string.perps_reduce_action), tint = Color.Unspecified, modifier = Modifier.size(16.dp)) } - Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Row(modifier = Modifier.weight(1f, fill = false), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { if (!isPercentage) Text(PERPS_USD_SYMBOL, style = inputTextStyle) BasicTextField( value = textFieldValue, @@ -745,7 +745,7 @@ private fun PerpsReduceMarginInput( ) { Text( text = if (isPercentage) formatPerpsMarginAmount(amount) else "${percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString() ?: "0"}%", - color = MixinAppTheme.colors.textAssist, + color = MixinAppTheme.colors.textRemarks, fontSize = 16.sp, lineHeight = 20.sp, ) @@ -765,7 +765,7 @@ private fun PerpsReduceMarginInput( ) { Text( text = errorText.orEmpty(), - color = MixinAppTheme.colors.walletRed, + color = MixinAppTheme.colors.badgeRed, fontSize = 12.sp, lineHeight = 16.sp, textAlign = TextAlign.Center, @@ -809,8 +809,9 @@ private fun PerpsReduceMarginInput( listOf(0, 25, 50, 75, 100).forEach { value -> Text( text = if (isPercentage) "$value%" else formatPerpsMarginAmount(reduceMarginAmount(margin?.toPlainString(), value.toString(), true)), - color = MixinAppTheme.colors.textPrimary, + color = MixinAppTheme.colors.textAssist, fontSize = 12.sp, + fontWeight = if (isPercentage) FontWeight.W500 else FontWeight.Normal, lineHeight = 16.sp, textAlign = TextAlign.Center, modifier = Modifier.clickable(enabled = enabled && margin != null && margin > BigDecimal.ZERO) { diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt index 7876dbc001..de7654497e 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -47,16 +47,16 @@ class PerpsMarginAmountTest { assertEquals("$2.07", formatPerpsMarginLimit("6.98", BigDecimal("2.079999999"), false)) assertEquals("$1.12", formatPerpsMarginLimit("10.93", BigDecimal("1.12139433"), false)) assertEquals("1.12", maximumMarginReductionInput("10.93", BigDecimal("1.12139433"), false)) - assertEquals("0", formatPerpsMarginLimit("1", BigDecimal("0.00000001"), false)) + assertEquals("$0", formatPerpsMarginLimit("1", BigDecimal("0.00000001"), false)) assertEquals("0%", formatPerpsMarginLimit("6.98", BigDecimal.ZERO, true)) - assertEquals("0", formatPerpsMarginLimit("6.98", BigDecimal.ZERO, false)) + assertEquals("$0", formatPerpsMarginLimit("6.98", BigDecimal.ZERO, false)) assertEquals("0%", formatPerpsMarginLimit("0", BigDecimal.ZERO, true)) } @Test - fun marginPreviewUsesZeroWithoutCurrencyAndTheActualLiquidationPrice() { + fun marginPreviewKeepsCurrencyForZeroAndTheActualLiquidationPrice() { listOf(null, BigDecimal.ZERO, BigDecimal("0.00"), BigDecimal("0.004")).forEach { - assertEquals("0", formatPerpsMarginAmount(it)) + assertEquals("$0", formatPerpsMarginAmount(it)) } assertEquals("$1.50", formatPerpsMarginAmount(BigDecimal("1.5"))) assertEquals(BigDecimal("130"), marginAfterAdjustment("100", "30", true)) From 969af91b9184ff9890b7ce63e952ede2ba5be3ba Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 21 Sep 2026 14:27:22 +0800 Subject: [PATCH 23/29] fix(perps): simplify chinese margin action labels --- app/src/main/res/values-zh-rCN/strings.xml | 4 ++-- app/src/main/res/values-zh-rTW/strings.xml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c43deae507..b3f73c84bb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2645,7 +2645,7 @@ %1$s %2$s 减少仓位 最多减少 %1$s 已加仓 - 已增加保证金 - 已减少保证金 + 增加保证金 + 减少保证金 已实现盈亏 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4cd2f46620..b82865e98c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2697,7 +2697,7 @@ %1$s %2$s 減少倉位 最多減少 %1$s 已加倉 - 已增加保證金 - 已減少保證金 + 增加保證金 + 減少保證金 已實現盈虧 From 1107e47fe84f95e186491db5eb6212ba1780549e Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 21 Sep 2026 15:52:42 +0800 Subject: [PATCH 24/29] fix(perps): tighten margin mode toggle spacing --- .../web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 7d8d1410bd..e536042c41 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -749,7 +749,6 @@ private fun PerpsReduceMarginInput( fontSize = 16.sp, lineHeight = 20.sp, ) - Spacer(Modifier.width(4.dp)) Icon( painterResource(R.drawable.ic_switch), stringResource(if (isPercentage) R.string.Amount else R.string.perps_percentage), From c9a155e3a0f70906ec495217b7ae96687308c41c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 22 Sep 2026 16:27:30 +0800 Subject: [PATCH 25/29] fix(perps): distinguish opening and adding limit errors --- .../trade/perps/LiquidationPriceRequest.kt | 14 ++++- .../home/web3/trade/perps/OpenPositionPage.kt | 19 +----- .../web3/trade/perps/PerpetualViewModel.kt | 4 +- .../PerpsAddBottomSheetDialogFragment.kt | 19 +----- .../PerpsMarginBottomSheetDialogFragment.kt | 6 +- app/src/main/res/values-es/strings.xml | 5 ++ app/src/main/res/values-in/strings.xml | 5 ++ app/src/main/res/values-ja/strings.xml | 5 ++ app/src/main/res/values-ko/strings.xml | 5 +- app/src/main/res/values-ms/strings.xml | 5 ++ app/src/main/res/values-ru/strings.xml | 5 ++ app/src/main/res/values-zh-rCN/strings.xml | 7 ++- app/src/main/res/values-zh-rTW/strings.xml | 9 +-- app/src/main/res/values/strings.xml | 5 +- .../trade/perps/LiquidationPriceLimitTest.kt | 63 +++++++++++++++++++ 15 files changed, 126 insertions(+), 50 deletions(-) create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceLimitTest.kt diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt index acbe83bfe1..a2741dd58c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt @@ -1,14 +1,26 @@ package one.mixin.android.ui.home.web3.trade.perps +import android.content.Context import com.google.gson.JsonElement import kotlinx.coroutines.delay +import one.mixin.android.R import one.mixin.android.util.ErrorHandler import java.math.BigDecimal internal data class LiquidationPriceLimit( val maxAmount: String?, val maxLeverage: Int?, -) +) { + fun errorMessage(context: Context, symbol: String, isAddingPosition: Boolean = false): String = + maxAmount?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO }?.let { + context.getString( + if (isAddingPosition) R.string.error_perps_position_size_exceeds_leverage_limit_add else R.string.error_perps_position_size_exceeds_leverage_limit_value, + "${it.stripTrailingZeros().toPlainString()} $symbol".trim(), + ) + } ?: context.getString( + if (isAddingPosition) R.string.error_perps_position_size_exceeds_leverage_limit_cannot_add else R.string.error_perps_position_size_exceeds_leverage_limit, + ) +} internal sealed interface LiquidationPriceResult { data class Success(val price: String) : LiquidationPriceResult 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 1b2c4b3753..0d5bcb8553 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 @@ -261,26 +261,11 @@ fun OpenPositionPage( currentToken?.symbol.orEmpty(), ) val maximumMarginError = stringResource( - R.string.perps_maximum_margin, + R.string.single_transaction_should_be_less_than, maximumMargin.stripTrailingZeros().toPlainString(), currentToken?.symbol.orEmpty(), ) - val liquidationLimitMaxAmount = liquidationPriceLimit?.maxAmount - ?.toBigDecimalOrNull() - ?.takeIf { it > BigDecimal.ZERO } - ?.stripTrailingZeros() - ?.toPlainString() - val liquidationLimitAmountError = liquidationLimitMaxAmount?.let { - stringResource(R.string.perps_maximum_margin, it, currentToken?.symbol.orEmpty()) - } - val liquidationLimitLeverageError = liquidationPriceLimit?.maxLeverage?.let { - stringResource(R.string.perps_maximum_leverage, it) - } - val liquidationLimitError = liquidationPriceLimit?.let { - listOfNotNull(liquidationLimitAmountError, liquidationLimitLeverageError) - .joinToString(". ") - .ifBlank { stringResource(R.string.error_perps_position_size_exceeds_leverage_limit) } - } + val liquidationLimitError = liquidationPriceLimit?.errorMessage(context, currentToken?.symbol.orEmpty()) val displayLiquidationPrice = remoteLiquidationPrice val marginLimitError = when { belowMinimumMargin -> minimumMarginError diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index 6d0f01b53f..b157fa7163 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -567,13 +567,13 @@ class PerpetualViewModel @Inject constructor( val minimum = market.minAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO val maximum = market.maxAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO if (leverage > market.leverage) { - return PerpsLinkPreview.Failure(context.getString(R.string.perps_maximum_leverage, market.leverage)) + return PerpsLinkPreview.Failure(context.getString(R.string.error_perps_position_size_exceeds_leverage_limit)) } if (minimum > BigDecimal.ZERO && amount < minimum) { return PerpsLinkPreview.Failure(context.getString(R.string.perps_minimum_margin, market.minAmount, market.quoteSymbol)) } if (maximum > BigDecimal.ZERO && amount > maximum) { - return PerpsLinkPreview.Failure(context.getString(R.string.perps_maximum_margin, market.maxAmount, market.quoteSymbol)) + return PerpsLinkPreview.Failure(context.getString(R.string.single_transaction_should_be_less_than, market.maxAmount, market.quoteSymbol)) } if ((market.last.toBigDecimalOrNull() ?: BigDecimal.ZERO) <= BigDecimal.ZERO) throw DataErrorException() val token = loadPerpsMarginToken() 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 aa98c05406..1204ed4b70 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 @@ -366,26 +366,11 @@ private fun PerpsAddContent( selectedToken?.symbol.orEmpty(), ) val maximumMarginError = stringResource( - R.string.perps_maximum_margin, + R.string.single_transaction_should_be_less_than, maximumMargin.stripTrailingZeros().toPlainString(), selectedToken?.symbol.orEmpty(), ) - val liquidationLimitMaxAmount = liquidationPriceLimit?.maxAmount - ?.toBigDecimalOrNull() - ?.takeIf { it > BigDecimal.ZERO } - ?.stripTrailingZeros() - ?.toPlainString() - val liquidationLimitAmountError = liquidationLimitMaxAmount?.let { - stringResource(R.string.perps_maximum_margin, it, selectedToken?.symbol.orEmpty()) - } - val liquidationLimitLeverageError = liquidationPriceLimit?.maxLeverage?.let { - stringResource(R.string.perps_maximum_leverage, it) - } - val liquidationLimitError = liquidationPriceLimit?.let { - listOfNotNull(liquidationLimitAmountError, liquidationLimitLeverageError) - .joinToString(". ") - .ifBlank { stringResource(R.string.error_perps_position_size_exceeds_leverage_limit) } - } + val liquidationLimitError = liquidationPriceLimit?.errorMessage(context, selectedToken?.symbol.orEmpty(), isAddingPosition = true) val marginLimitError = when { belowMinimumMargin -> minimumMarginError aboveMaximumMargin -> maximumMarginError diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index e536042c41..8a3f97012c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -182,6 +182,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val normalizedAmount = amountValue?.stripTrailingZeros()?.toPlainString().orEmpty() var remoteLiquidationPrice by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } var liquidationError by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } + var liquidationPriceLimit by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } var isLiquidationLoading by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(false) } val totalMargin = marginAfterAdjustment(currentPosition.margin, normalizedAmount, increase, availableMargin) val marginValue = currentPosition.margin?.toBigDecimalOrNull()?.takeIf { it >= BigDecimal.ZERO } @@ -217,7 +218,7 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme delay(200L) remoteLiquidationPrice = requestLiquidationPrice( onFailure = { liquidationError = it ?: getString(R.string.Data_error) }, - onLimitExceeded = { liquidationError = getString(R.string.error_perps_position_size_exceeds_leverage_limit) }, + onLimitExceeded = { liquidationPriceLimit = it }, onMarginExceeded = if (increase) null else { available -> availableMargin = available liquidationError = if (available == null) getString(R.string.Data_error) else null @@ -344,7 +345,8 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme val limit = formatPerpsMarginLimit(currentPosition.margin, maximumReduction, reduceByPercent) stringResource(R.string.max_removable, limit) } else null - val errorText = if (insufficientBalance) stringResource(R.string.insufficient_balance) else error ?: liquidationError + val errorText = if (insufficientBalance) stringResource(R.string.insufficient_balance) else + error ?: liquidationPriceLimit?.errorMessage(requireContext(), selectedToken?.symbol.orEmpty()) ?: liquidationError val onSubmit = { if (increase) { submit(normalizedAmount) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b54b26aedb..4f909f65a0 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1218,4 +1218,9 @@ Margen añadido Margen reducido PnL realizado + ERROR 10655: El tamaño de la posición supera el límite para el apalancamiento seleccionado. Reduzca el importe o el apalancamiento. + ERROR 10655: El importe de apertura supera el límite máximo de %1$s. Reduzca el importe. + ERROR 10655: No se puede ampliar la posición. + ERROR 10655: El importe de apertura supera el límite máximo de %1$s. Reduzca el importe o el apalancamiento. + Cantidad máxima %1$s %2$s diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 3bfd6c9005..9688cb9d3e 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -776,4 +776,9 @@ Margin Ditambahkan Margin Dikurangi PnL Terealisasi + KESALAHAN 10655: Ukuran posisi melebihi batas untuk leverage yang dipilih. Kurangi jumlah atau leverage. + KESALAHAN 10655: Jumlah pembukaan posisi melebihi batas maksimum %1$s. Kurangi jumlah. + KESALAHAN 10655: Tidak dapat menambah posisi. + KESALAHAN 10655: Jumlah pembukaan posisi melebihi batas maksimum %1$s. Kurangi jumlah atau leverage. + Jumlah maksimum %1$s %2$s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 877ec0a2ab..1464c06fa5 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1164,4 +1164,9 @@ 証拠金追加 証拠金減額 実現損益 + エラー 10655:ポジションサイズが選択したレバレッジの上限を超えています。金額またはレバレッジを下げてください。 + エラー 10655:新規建玉の金額が上限の%1$sを超えています。金額を下げてください。 + エラー 10655:建玉を追加できません。 + エラー 10655:新規建玉の金額が上限の%1$sを超えています。金額またはレバレッジを下げてください。 + 最大金額 %1$s %2$s diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d798c2db95..f3f8aa91f2 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -491,6 +491,9 @@ 오류 10651: 이미 오픈 포지션이 있습니다. 오류 10652: 현재 시장에서 아직 다른 주문을 처리 중입니다. 나중에 다시 시도하십시오. 오류 10655: 포지션 크기가 선택한 레버리지 한도를 초과했습니다. 금액 또는 레버리지를 낮추세요. + 오류 10655: 포지션 개설 금액이 최대 한도 %1$s을(를) 초과했습니다. 금액을 낮추세요. + 오류 10655: 포지션을 추가할 수 없습니다. + 오류 10655: 포지션 개설 금액이 최대 한도 %1$s을(를) 초과했습니다. 금액 또는 레버리지를 낮추세요. 오류 20114: 만료된 전화 확인 코드 오류 20113: 잘못된 전화 확인 코드 5회 이상 시도하셨습니다. 다시 시도하려면 최소 24시간 정도 기다려 주십시오. @@ -1653,8 +1656,6 @@ 최대 금액 %1$s %2$s 금액은 %1$s %2$s에서 %3$s %4$s 사이여야 합니다. 최소 금액은 %1$s %2$s입니다. - 최대 금액은 %1$s %2$s입니다. - 이 금액의 최대 레버리지는 %1$dx입니다. 승인 대출 굽기 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index cc002904be..5339e01074 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -841,4 +841,9 @@ Margin Ditambah Margin Dikurangkan PnL Direalisasikan + RALAT 10655: Saiz posisi melebihi had untuk leveraj yang dipilih. Kurangkan jumlah atau leveraj. + RALAT 10655: Amaun pembukaan posisi melebihi had maksimum %1$s. Kurangkan amaun. + RALAT 10655: Posisi tidak dapat ditambah. + RALAT 10655: Amaun pembukaan posisi melebihi had maksimum %1$s. Kurangkan amaun atau leveraj. + Jumlah maksimum %1$s %2$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 82e383b22b..2ea7aab507 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1159,4 +1159,9 @@ Маржа добавлена Маржа уменьшена Реализованная прибыль/убыток + ОШИБКА 10655: Размер позиции превышает лимит для выбранного кредитного плеча. Уменьшите сумму или кредитное плечо. + ОШИБКА 10655: Сумма открытия позиции превышает максимальный лимит %1$s. Уменьшите сумму. + ОШИБКА 10655: Невозможно увеличить позицию. + ОШИБКА 10655: Сумма открытия позиции превышает максимальный лимит %1$s. Уменьшите сумму или кредитное плечо. + Максимальная сумма %1$s %2$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b3f73c84bb..77ba89d995 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -487,6 +487,9 @@ 错误 10651:当前已有持仓 当前市场已开仓。 错误 10655:仓位规模超过当前杠杆档位限制,请降低金额或杠杆。 + 错误 10655:开仓金额超出最大限额 %1$s,请减少金额。 + 错误 10655:无法加仓。 + 错误 10655:开仓金额超出最大限额 %1$s,请减少金额或降低杠杆。 错误 10656:跟单目标仓位无效,请重新选择跟单仓位。 错误 20114:验证码已过期 错误 20113:验证码错误 @@ -1629,8 +1632,6 @@ 最高金额 %1$s %2$s 金额范围 %1$s %2$s ~ %3$s %4$s 最小金额为 %1$s %2$s - 最大金额为 %1$s %2$s - 当前金额支持的最大杠杆为 %1$dx 授权 借款 @@ -2644,7 +2645,7 @@ %1$s %2$s 增加仓位 %1$s %2$s 减少仓位 最多减少 %1$s - 已加仓 + 加仓 增加保证金 减少保证金 已实现盈亏 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b82865e98c..76082157f8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1313,7 +1313,10 @@ 錯誤 10654:倉位價值至少 %1$s 錯誤 10651:當前已有持倉 錯誤10652:目前市場仍在處理其他訂單,請稍後重試。 - 錯誤 10655:部位規模超過目前槓桿檔位限制,請降低金額或槓桿。 + 錯誤 10655:倉位規模超過當前槓桿檔位限制,請降低金額或槓桿。 + 錯誤 10655:開倉金額超出最大限額 %1$s,請減少金額。 + 錯誤 10655:無法加倉。 + 錯誤 10655:開倉金額超出最大限額 %1$s,請減少金額或降低槓桿。 錯誤 10633:地址不可用 錯誤 10730:無效的邀請碼 錯誤 10731:當前賬號已繫結邀請碼,無法修改繫結。 @@ -1776,8 +1779,6 @@ 最高金額 %1$s %2$s 金額必須介於 %1$s %2$s 與 %3$s %4$s 之間 最低金額為 %1$s %2$s - 最大金額為%1$s %2$s - 目前金額支援的最大槓桿為 %1$dx 批准 借款 燃燒 @@ -2696,7 +2697,7 @@ %1$s %2$s 增加倉位 %1$s %2$s 減少倉位 最多減少 %1$s - 已加倉 + 加倉 增加保證金 減少保證金 已實現盈虧 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc8c51c7f9..3d50ffc859 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -499,6 +499,9 @@ ERROR 10651: You already had a open position ERROR 10652: The current market is still processing other orders, please try again later. ERROR 10655: Position size exceeds the limit for the selected leverage. Reduce the amount or leverage. + ERROR 10655: Amount exceeds the %1$s limit. Reduce the amount. + ERROR 10655: Cannot add to the position. + ERROR 10655: Amount exceeds the %1$s limit. Reduce the amount or leverage. ERROR 10656: The leader position is invalid. Please select another position to copy. ERROR 20114: Expired phone verification code ERROR 20113: Invalid phone verification code @@ -1684,8 +1687,6 @@ Maximum amount %1$s %2$s Amount must be between %1$s %2$s and %3$s %4$s The minimum amount is %1$s %2$s - The maximum amount is %1$s %2$s - The maximum leverage for this amount is %1$dx Approve Borrow diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceLimitTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceLimitTest.kt new file mode 100644 index 0000000000..63a4abae09 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceLimitTest.kt @@ -0,0 +1,63 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import androidx.test.core.app.ApplicationProvider +import com.google.gson.JsonParser +import one.mixin.android.R +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, manifest = Config.NONE, sdk = [28]) +class LiquidationPriceLimitTest { + @Test + fun showsOnlyMaximumAmountAndFallsBackWhenItIsInvalid() { + val application = ApplicationProvider.getApplicationContext() + val resources = object : Resources(application.assets, application.resources.displayMetrics, application.resources.configuration) { + override fun getString(id: Int): String = when (id) { + R.string.error_perps_position_size_exceeds_leverage_limit_value -> "错误 10655:开仓金额超出最大限额 %1\$s,请减少金额或降低杠杆。" + R.string.error_perps_position_size_exceeds_leverage_limit_add -> "错误 10655:开仓金额超出最大限额 %1\$s,请减少金额。" + R.string.error_perps_position_size_exceeds_leverage_limit_cannot_add -> "错误 10655:无法加仓。" + R.string.error_perps_position_size_exceeds_leverage_limit -> "ERROR 10655" + else -> error("Unexpected string resource: $id") + } + + override fun getString(id: Int, vararg formatArgs: Any?): String = getString(id).format(*formatArgs) + } + val context = object : ContextWrapper(application) { + override fun getResources(): Resources = resources + } + for ((extra, expected) in listOf( + """{"max_amount":"24.7500","max_leverage":5}""" to "错误 10655:开仓金额超出最大限额 24.75 USDT,请减少金额或降低杠杆。", + """{"max_amount":0.25}""" to "错误 10655:开仓金额超出最大限额 0.25 USDT,请减少金额或降低杠杆。", + """{"max_leverage":"3"}""" to "ERROR 10655", + """{"max_amount":"invalid","max_leverage":2}""" to "ERROR 10655", + """{"max_amount":"0","max_leverage":0}""" to "ERROR 10655", + """{"max_amount":"-1","max_leverage":-1}""" to "ERROR 10655", + """{"max_amount":{},"max_leverage":null}""" to "ERROR 10655", + "null" to "ERROR 10655", + )) { + assertEquals(expected, parseLiquidationPriceLimit(JsonParser.parseString(extra)).errorMessage(context, "USDT")) + } + assertEquals("错误 10655:开仓金额超出最大限额 100,请减少金额或降低杠杆。", LiquidationPriceLimit("100", null).errorMessage(context, "")) + assertEquals( + "错误 10655:开仓金额超出最大限额 24.75 USDT,请减少金额。", + LiquidationPriceLimit("24.7500", 5).errorMessage(context, "USDT", isAddingPosition = true), + ) + for (extra in listOf("""{"max_amount":0}""", """{"max_amount":"0.0000"}""")) { + assertEquals( + "错误 10655:无法加仓。", + parseLiquidationPriceLimit(JsonParser.parseString(extra)).errorMessage(context, "USDT", isAddingPosition = true), + ) + } + for (amount in listOf(null, "invalid", "-1")) { + assertEquals("错误 10655:无法加仓。", LiquidationPriceLimit(amount, null).errorMessage(context, "USDT", isAddingPosition = true)) + } + } +} From eab58b429b24d1c7449dc9edc030da0f837b0678 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 24 Sep 2026 15:30:53 +0800 Subject: [PATCH 26/29] fix(perps): keep liquidation previews stable on refresh --- .../trade/perps/LiquidationPriceRequest.kt | 18 ++++++++ .../PerpsAddBottomSheetDialogFragment.kt | 33 +++++---------- .../PerpsMarginBottomSheetDialogFragment.kt | 21 +++++----- .../perps/LiquidationPriceRequestTest.kt | 41 +++++++++++++++++++ 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt index a2741dd58c..7ad901470e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt @@ -1,12 +1,30 @@ package one.mixin.android.ui.home.web3.trade.perps import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.google.gson.JsonElement import kotlinx.coroutines.delay import one.mixin.android.R import one.mixin.android.util.ErrorHandler import java.math.BigDecimal +internal class LiquidationPriceState { + var price by mutableStateOf(null) + var isLoading by mutableStateOf(false) + private set + + suspend fun refresh(request: suspend () -> String?) { + isLoading = price == null + try { + price = request() + } finally { + isLoading = false + } + } +} + internal data class LiquidationPriceLimit( val maxAmount: String?, val maxLeverage: Int?, 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 1204ed4b70..8becc5ab51 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 @@ -69,7 +69,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.withResumed import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import one.mixin.android.Constants @@ -298,13 +297,15 @@ private fun PerpsAddContent( var amount by remember(position.positionId, initialMargin) { mutableStateOf(limitTradeInputDecimalPlaces(initialMargin.orEmpty(), TRADE_INPUT_MAX_DECIMAL_PLACES)) } - var remoteLiquidationPrice by remember(position.positionId) { mutableStateOf(null) } - var liquidationPriceLimit by remember(position.positionId) { mutableStateOf(null) } - var liquidationError by remember(position.positionId) { mutableStateOf(null) } - var isLiquidationLoading by remember(position.positionId) { mutableStateOf(false) } - var liquidationJob by remember(position.positionId) { mutableStateOf(null) } val tokenBalance = selectedToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO val amountValue = amount.toBigDecimalOrNull() + val normalizedAmount = amountValue?.stripTrailingZeros()?.toPlainString() + ?.let { limitTradeInputDecimalPlaces(it, TRADE_INPUT_MAX_DECIMAL_PLACES) }.orEmpty() + val liquidationState = remember(position.positionId, amount) { LiquidationPriceState() } + val remoteLiquidationPrice = liquidationState.price + val isLiquidationLoading = liquidationState.isLoading + var liquidationPriceLimit by remember(position.positionId) { mutableStateOf(null) } + var liquidationError by remember(position.positionId) { mutableStateOf(null) } val hasInputAmount = amountValue != null && amountValue > BigDecimal.ZERO val minimumMargin = market?.minAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO @@ -330,24 +331,13 @@ private fun PerpsAddContent( LaunchedEffect(amount, belowMinimumMargin, aboveMaximumMargin, position.updatedAt, market?.last) { liquidationPriceLimit = null liquidationError = null - val addMargin = amount.toBigDecimalOrNull() - if (!shouldRequestLiquidationPrice(addMargin, minimumMargin) || aboveMaximumMargin) { - liquidationJob?.cancel() - remoteLiquidationPrice = null - isLiquidationLoading = false + if (!shouldRequestLiquidationPrice(amountValue, minimumMargin) || aboveMaximumMargin) { + liquidationState.price = null return@LaunchedEffect } - val requestAmount = addMargin ?: return@LaunchedEffect - liquidationJob?.cancel() - liquidationJob = launch { - remoteLiquidationPrice = null - isLiquidationLoading = true + liquidationState.refresh { delay(200L) - val normalizedAmount = requestAmount - .stripTrailingZeros() - .toPlainString() - .let { limitTradeInputDecimalPlaces(it, TRADE_INPUT_MAX_DECIMAL_PLACES) } - remoteLiquidationPrice = requestLiquidationPrice( + requestLiquidationPrice( onLimitExceeded = { liquidationPriceLimit = it }, onFailure = { liquidationError = it ?: context.getString(R.string.Data_error) }, ) { @@ -356,7 +346,6 @@ private fun PerpsAddContent( positionId = position.positionId, ) } - isLiquidationLoading = false } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt index 8a3f97012c..6b0c9daca6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -180,10 +180,11 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } val amountValue = if (increase) marginAdjustmentAmount(amount) else reduceMarginAmount(currentPosition.margin, amount, inputIsPercentage) val normalizedAmount = amountValue?.stripTrailingZeros()?.toPlainString().orEmpty() - var remoteLiquidationPrice by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } + val liquidationState = remember(currentPosition.positionId, normalizedAmount, increase) { LiquidationPriceState() } + val remoteLiquidationPrice = liquidationState.price + val isLiquidationLoading = liquidationState.isLoading var liquidationError by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } var liquidationPriceLimit by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(null) } - var isLiquidationLoading by remember(normalizedAmount, increase, currentPosition.updatedAt) { mutableStateOf(false) } val totalMargin = marginAfterAdjustment(currentPosition.margin, normalizedAmount, increase, availableMargin) val marginValue = currentPosition.margin?.toBigDecimalOrNull()?.takeIf { it >= BigDecimal.ZERO } val maximumReduction = marginValue?.let { margin -> availableMargin?.min(margin) ?: margin } @@ -210,13 +211,15 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme } LaunchedEffect(normalizedAmount, increase, currentPosition.updatedAt, exceedsReductionLimit) { - remoteLiquidationPrice = null - isLiquidationLoading = false - if (totalMargin == null || exceedsReductionLimit) return@LaunchedEffect - isLiquidationLoading = true - try { + liquidationError = null + liquidationPriceLimit = null + if (totalMargin == null || exceedsReductionLimit) { + liquidationState.price = null + return@LaunchedEffect + } + liquidationState.refresh { delay(200L) - remoteLiquidationPrice = requestLiquidationPrice( + requestLiquidationPrice( onFailure = { liquidationError = it ?: getString(R.string.Data_error) }, onLimitExceeded = { liquidationPriceLimit = it }, onMarginExceeded = if (increase) null else { available -> @@ -230,8 +233,6 @@ class PerpsMarginBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragme action = if (increase) "increase_margin" else "decrease_margin", ) } - } finally { - isLiquidationLoading = false } } LaunchedEffect(initialPosition.positionId, lifecycleOwner) { diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt index e0426329e4..62db0c825b 100644 --- a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequestTest.kt @@ -1,6 +1,10 @@ package one.mixin.android.ui.home.web3.trade.perps import com.google.gson.JsonParser +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import okhttp3.Protocol @@ -10,14 +14,51 @@ import okhttp3.ResponseBody.Companion.toResponseBody import one.mixin.android.api.service.RouteService import one.mixin.android.util.ErrorHandler import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.math.BigDecimal class LiquidationPriceRequestTest { + @Test + fun refreshShowsLoadingOnlyUntilItHasAPrice() = runBlocking { + val state = LiquidationPriceState() + val initialPrice = CompletableDeferred() + val initialRequest = launch(start = CoroutineStart.UNDISPATCHED) { + state.refresh { initialPrice.await() } + } + assertTrue(state.isLoading) + assertNull(state.price) + initialPrice.complete("123.45") + initialRequest.join() + assertFalse(state.isLoading) + assertEquals("123.45", state.price) + + val updatedPrice = CompletableDeferred() + val refresh = launch(start = CoroutineStart.UNDISPATCHED) { + state.refresh { updatedPrice.await() } + } + assertFalse(state.isLoading) + assertEquals("123.45", state.price) + updatedPrice.complete("124.56") + refresh.join() + assertFalse(state.isLoading) + assertEquals("124.56", state.price) + + state.refresh { null } + assertNull(state.price) + val retry = launch(start = CoroutineStart.UNDISPATCHED) { + state.refresh { CompletableDeferred().await() } + } + assertTrue(state.isLoading) + retry.cancelAndJoin() + assertFalse(state.isLoading) + } + @Test fun marginLimitReportsTheServerErrorWithoutAReductionHandler() = runBlocking { var errorMessage: String? = null From b2825edd257d5ec0ba162dcba52a0641abce2a60 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 28 Sep 2026 10:25:18 +0800 Subject: [PATCH 27/29] fix(perps): correct tp/sl calculations after margin adjustments --- .gitignore | 1 + .../web3/trade/perps/PerpsMarketDetailPage.kt | 3 + .../PerpsTpSlBottomSheetDialogFragment.kt | 231 ++++++------------ .../web3/trade/perps/PerpsTpSlCalculation.kt | 125 ++++++++++ .../trade/perps/PerpsTpSlCalculationTest.kt | 186 ++++++++++++++ 5 files changed, 394 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlCalculation.kt create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlCalculationTest.kt diff --git a/.gitignore b/.gitignore index 287cdc5f06..01d6e2a53e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /build build/ /captures +/docs/ google-services.json .DS_Store /.kotlin diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 3447825444..fd9018ca7f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -435,6 +435,8 @@ fun PerpsMarketDetailPage( entryPrice = currentPosition.entryPrice, marketId = currentPosition.marketId, priceScale = currentPosition.priceScale, + liquidationPrice = currentPosition.liquidationPrice, + positionQuantity = currentPosition.quantity, ).setOnApply { value -> val normalizedValue = value?.trim().orEmpty() if (normalizedValue == existingPrice) return@setOnApply @@ -1170,6 +1172,7 @@ private fun OpenPositionCard( marketId = position.marketId, priceScale = position.priceScale, liquidationPrice = position.liquidationPrice, + positionQuantity = position.quantity, ).setOnApply { value -> val normalizedValue = value?.trim().orEmpty() if (normalizedValue == existingPrice) { 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 013ebc346f..9b56d2ebfd 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 @@ -116,6 +116,7 @@ class PerpsTpSlBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment private const val ARGS_MARKET_ID = "args_market_id" private const val ARGS_PRICE_SCALE = "args_price_scale" private const val ARGS_LIQUIDATION_PRICE = "args_liquidation_price" + private const val ARGS_POSITION_QUANTITY = "args_position_quantity" fun newInstance( mode: Mode, @@ -130,6 +131,7 @@ class PerpsTpSlBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment marketId: String? = null, priceScale: Int, liquidationPrice: String? = null, + positionQuantity: String? = null, ): PerpsTpSlBottomSheetDialogFragment { return PerpsTpSlBottomSheetDialogFragment().withArgs { putString(ARGS_MODE, mode.name) @@ -144,6 +146,7 @@ class PerpsTpSlBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment putString(ARGS_MARKET_ID, marketId) putInt(ARGS_PRICE_SCALE, priceScale) putString(ARGS_LIQUIDATION_PRICE, liquidationPrice) + putString(ARGS_POSITION_QUANTITY, positionQuantity) } } } @@ -166,6 +169,7 @@ class PerpsTpSlBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment args.getInt(ARGS_PRICE_SCALE) } private val liquidationPrice by lazy { requireArguments().getString(ARGS_LIQUIDATION_PRICE) } + private val positionQuantity by lazy { requireArguments().getString(ARGS_POSITION_QUANTITY) } private var onApply: ((String?) -> Unit)? = null @@ -223,6 +227,7 @@ class PerpsTpSlBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment marketId = marketId, priceScale = priceScale, liquidationPrice = liquidationPrice, + positionQuantity = positionQuantity, onCancel = { dismiss() }, onApply = { value -> onApply?.invoke(value) @@ -257,6 +262,7 @@ private fun PerpsTpSlContent( marketId: String, priceScale: Int, liquidationPrice: String?, + positionQuantity: String?, onCancel: () -> Unit, onApply: (String?) -> Unit, ) { @@ -285,20 +291,34 @@ private fun PerpsTpSlContent( val entryPriceValue = remember(entryPrice) { entryPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } } - val liquidationPriceValue = remember(liquidationPrice) { - liquidationPrice?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + val pnlBasis = remember(entryPrice, latestCurrentPrice, marginAmount, positionQuantity, leverage) { + resolveTpSlCalculationBasis( + entryPrice = entryPrice, + currentPrice = latestCurrentPrice, + marginAmount = marginAmount, + positionQuantity = positionQuantity, + leverage = leverage, + ) } - val percentBasePrice = remember(entryPriceValue, validationCurrentPrice) { - entryPriceValue ?: validationCurrentPrice + val conversionBasis = remember(entryPrice, latestCurrentPrice, marginAmount, positionQuantity, leverage) { + resolveTpSlConversionBasis( + entryPrice = entryPrice, + currentPrice = latestCurrentPrice, + marginAmount = marginAmount, + positionQuantity = positionQuantity, + leverage = leverage, + ) } - val liquidationBasePrice = remember(entryPriceValue, validationCurrentPrice) { - liquidationPriceValue - ?: entryPriceValue - ?: validationCurrentPrice.takeIf { it > BigDecimal.ZERO } - ?: BigDecimal.ZERO + val liquidationBound = remember(liquidationPrice, entryPrice, latestCurrentPrice, leverage, isLong) { + resolveTpSlLiquidationBound( + serverLiquidationPrice = liquidationPrice, + entryPrice = entryPrice, + currentPrice = latestCurrentPrice, + leverage = leverage, + isLong = isLong, + ) } val hasEntryPrice = entryPriceValue != null - val leverageValue = leverage.coerceAtLeast(1) val storedInputType = remember(preferences) { preferences.getString(PREF_TPSL_INPUT_TYPE, null) ?.let { stored -> InputType.values().firstOrNull { it.name == stored } } @@ -321,8 +341,7 @@ private fun PerpsTpSlContent( normalizePercentInput( derivePercentMagnitudeInput( priceInput = normalizedInitialPrice, - percentBasePrice = percentBasePrice, - leverage = leverageValue, + calculationBasis = conversionBasis, isLong = isLong, mode = mode, ) @@ -337,17 +356,15 @@ private fun PerpsTpSlContent( val priceErrorText = validateTpSlPrice( rawValue = priceInput, currentPrice = validationCurrentPrice, - liquidationBasePrice = liquidationBasePrice, - leverage = leverageValue, + liquidationBound = liquidationBound, isLong = isLong, isTakeProfit = isTakeProfit, ) val percentErrorText = validateTpSlPercent( rawValue = percentMagnitudeInput, currentPrice = validationCurrentPrice, - percentBasePrice = percentBasePrice, - liquidationBasePrice = liquidationBasePrice, - leverage = leverageValue, + calculationBasis = conversionBasis, + liquidationBound = liquidationBound, isLong = isLong, mode = mode, priceScale = safePriceScale, @@ -419,12 +436,11 @@ private fun PerpsTpSlContent( mutableStateOf(System.currentTimeMillis() >= preferences.getTpSlGuideHideUntil(guideType)) } - LaunchedEffect(latestCurrentPrice, inputType, hasEntryPrice, percentMagnitudeInput, leverageValue, isLong, mode) { + LaunchedEffect(latestCurrentPrice, inputType, hasEntryPrice, percentMagnitudeInput, conversionBasis, isLong, mode) { if (hasEntryPrice || inputType != InputType.PNL) return@LaunchedEffect val recalculatedPrice = percentToPriceInput( percentMagnitudeInput = percentMagnitudeInput, - percentBasePrice = percentBasePrice, - leverage = leverageValue, + calculationBasis = conversionBasis, isLong = isLong, mode = mode, priceScale = safePriceScale, @@ -587,8 +603,7 @@ private fun PerpsTpSlContent( priceFieldValue = textFieldValueAtEnd( percentToPriceInput( percentMagnitudeInput = normalized, - percentBasePrice = percentBasePrice, - leverage = leverageValue, + calculationBasis = conversionBasis, isLong = isLong, mode = mode, priceScale = safePriceScale, @@ -605,8 +620,7 @@ private fun PerpsTpSlContent( normalizePercentInput( derivePercentMagnitudeInput( priceInput = normalized, - percentBasePrice = percentBasePrice, - leverage = leverageValue, + calculationBasis = conversionBasis, isLong = isLong, mode = mode, ) @@ -635,8 +649,7 @@ private fun PerpsTpSlContent( priceFieldValue = textFieldValueAtEnd( percentToPriceInput( percentMagnitudeInput = option, - percentBasePrice = percentBasePrice, - leverage = leverageValue, + calculationBasis = conversionBasis, isLong = isLong, mode = mode, priceScale = safePriceScale, @@ -650,14 +663,10 @@ private fun PerpsTpSlContent( Spacer(modifier = Modifier.height(10.dp)) val pnlPreview = calculateTpSlPnlPreview( - inputType = inputType, priceInput = priceInput, - percentMagnitudeInput = percentMagnitudeInput, - percentBasePrice = percentBasePrice, - leverage = leverageValue, + calculationBasis = pnlBasis, isLong = isLong, mode = mode, - marginAmount = marginAmount, ) val quoteColorReversed = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) @@ -788,8 +797,7 @@ private fun PerpsTpSlContent( val value = when (inputType) { InputType.PNL -> percentToPriceInput( percentMagnitudeInput = percentMagnitudeInput.trim(), - percentBasePrice = percentBasePrice, - leverage = leverageValue, + calculationBasis = conversionBasis, isLong = isLong, mode = mode, priceScale = safePriceScale, @@ -1105,134 +1113,63 @@ private data class TpSlPnlPreview( ) private fun calculateTpSlPnlPreview( - inputType: InputType, priceInput: String, - percentMagnitudeInput: String, - percentBasePrice: BigDecimal, - leverage: Int, + calculationBasis: TpSlCalculationBasis?, isLong: Boolean, mode: PerpsTpSlBottomSheetDialogFragment.Mode, - marginAmount: String, ): TpSlPnlPreview? { - val marginValue = marginAmount.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null - val exactPnlPercent = when (inputType) { - InputType.PNL -> percentMagnitudeInput.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } - InputType.PRICE -> absolutePnlPercentFromPrice( - priceInput = priceInput, - percentBasePrice = percentBasePrice, - leverage = leverage, - ) - } ?: return null - val exactPnlAmount = marginValue.multiply(exactPnlPercent).divide(BigDecimal(100), 8, RoundingMode.HALF_UP) - if (exactPnlAmount <= BigDecimal.ZERO) { - return null - } - if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.STOP_LOSS && exactPnlAmount > marginValue) { - return null - } + val basis = calculationBasis ?: return null + val targetPrice = priceInput.toBigDecimalOrNull() ?: return null + val result = basis.pnlAt(targetPrice, isLong) ?: return null + if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT && result.signedPnl <= BigDecimal.ZERO) return null + if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.STOP_LOSS && result.signedPnl >= BigDecimal.ZERO) return null + if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.STOP_LOSS && result.signedPnl.abs() > basis.margin) return null return TpSlPnlPreview( - percent = exactPnlPercent, - amount = exactPnlAmount, + percent = result.signedRoiPercent.abs(), + amount = result.signedPnl.abs(), ) } -private fun absolutePnlPercentFromPrice( - priceInput: String, - percentBasePrice: BigDecimal, - leverage: Int, -): BigDecimal? { - val targetPrice = priceInput.toBigDecimalOrNull() ?: return null - if (targetPrice <= BigDecimal.ZERO || percentBasePrice <= BigDecimal.ZERO || leverage <= 0) { - return null - } - return targetPrice - .subtract(percentBasePrice) - .abs() - .multiply(BigDecimal(100)) - .divide(percentBasePrice, 8, RoundingMode.HALF_UP) - .multiply(BigDecimal(leverage)) - .takeIf { it > BigDecimal.ZERO } -} - private fun percentToPriceInput( percentMagnitudeInput: String, - percentBasePrice: BigDecimal, - leverage: Int, + calculationBasis: TpSlCalculationBasis?, isLong: Boolean, mode: PerpsTpSlBottomSheetDialogFragment.Mode, priceScale: Int, ): String { - val magnitude = percentMagnitudeInput.toBigDecimalOrNull() ?: return "" - if (percentBasePrice <= BigDecimal.ZERO || leverage <= 0) { - return "" - } - val signedPercent = if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) { - magnitude - } else { - magnitude.negate() - } - val marketDeltaPercent = if (isLong) { - signedPercent.divide(BigDecimal(leverage), 8, RoundingMode.HALF_UP) - } else { - signedPercent.negate().divide(BigDecimal(leverage), 8, RoundingMode.HALF_UP) - } - val multiplier = BigDecimal.ONE + marketDeltaPercent.divide(BigDecimal(100), 8, RoundingMode.HALF_UP) - if (multiplier <= BigDecimal.ZERO) { - return "" - } - return formatPerpsPriceInput(percentBasePrice.multiply(multiplier), priceScale) + val basis = calculationBasis ?: return "" + return targetPriceForRoiPercent( + basis = basis, + percentMagnitudeInput = percentMagnitudeInput, + isLong = isLong, + isTakeProfit = mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT, + priceScale = priceScale, + ) } private fun derivePercentMagnitudeInput( priceInput: String, - percentBasePrice: BigDecimal, - leverage: Int, + calculationBasis: TpSlCalculationBasis?, isLong: Boolean, mode: PerpsTpSlBottomSheetDialogFragment.Mode, ): String { - val signedPercent = signedPercentFromPrice( - priceInput = priceInput, - percentBasePrice = percentBasePrice, - leverage = leverage, - isLong = isLong, - mode = mode, - ) ?: return "" - return signedPercent.abs().setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() -} - -private fun signedPercentFromPrice( - priceInput: String, - percentBasePrice: BigDecimal, - leverage: Int, - isLong: Boolean, - mode: PerpsTpSlBottomSheetDialogFragment.Mode, -): BigDecimal? { - val targetPrice = priceInput.toBigDecimalOrNull() ?: return null - if (targetPrice <= BigDecimal.ZERO || percentBasePrice <= BigDecimal.ZERO || leverage <= 0) { - return null - } - val marketDeltaPercent = targetPrice - .subtract(percentBasePrice) - .multiply(BigDecimal(100)) - .divide(percentBasePrice, 8, RoundingMode.HALF_UP) - val signedPercent = if (isLong) { - marketDeltaPercent.multiply(BigDecimal(leverage)) + val basis = calculationBasis ?: return "" + val targetPrice = priceInput.toBigDecimalOrNull() ?: return "" + val result = basis.pnlAt(targetPrice, isLong) ?: return "" + val correctDirection = if (mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT) { + result.signedPnl > BigDecimal.ZERO } else { - marketDeltaPercent.negate().multiply(BigDecimal(leverage)) - } - return when { - mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT && signedPercent > BigDecimal.ZERO -> signedPercent - mode == PerpsTpSlBottomSheetDialogFragment.Mode.STOP_LOSS && signedPercent < BigDecimal.ZERO -> signedPercent - else -> null + result.signedPnl < BigDecimal.ZERO } + if (!correctDirection) return "" + return result.signedRoiPercent.abs().setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() } internal fun validateTpSlPrice( rawValue: String, currentPrice: BigDecimal, - liquidationBasePrice: BigDecimal, - leverage: Int, + liquidationBound: BigDecimal?, isLong: Boolean, isTakeProfit: Boolean, ): String? { @@ -1248,14 +1185,6 @@ internal fun validateTpSlPrice( if (currentPrice <= BigDecimal.ZERO) { return null } - if (leverage <= 0) { - return null - } - - val liquidationOffset = BigDecimal.ONE.divide(BigDecimal(leverage), 8, RoundingMode.HALF_UP) - val liquidationPriceLong = liquidationBasePrice.multiply(BigDecimal.ONE.subtract(liquidationOffset)) - val liquidationPriceShort = liquidationBasePrice.multiply(BigDecimal.ONE.add(liquidationOffset)) - return when { isLong && isTakeProfit -> { if (price <= currentPrice) { @@ -1271,9 +1200,9 @@ internal fun validateTpSlPrice( R.string.the_price_must_lower_than, "$PERPS_USD_SYMBOL${currentPrice.stripTrailingZeros().toPlainString()}", ) - price < liquidationPriceLong -> MixinApplicationHolder.getString( + liquidationBound != null && !isStopBeyondLiquidation(price, liquidationBound, isLong = true) -> MixinApplicationHolder.getString( R.string.the_price_must_higher_than, - "$PERPS_USD_SYMBOL${liquidationPriceLong.stripTrailingZeros().toPlainString()}", + "$PERPS_USD_SYMBOL${liquidationBound.stripTrailingZeros().toPlainString()}", ) else -> null } @@ -1294,9 +1223,9 @@ internal fun validateTpSlPrice( R.string.the_price_must_higher_than, "$PERPS_USD_SYMBOL${currentPrice.stripTrailingZeros().toPlainString()}", ) - price > liquidationPriceShort -> MixinApplicationHolder.getString( + liquidationBound != null && !isStopBeyondLiquidation(price, liquidationBound, isLong = false) -> MixinApplicationHolder.getString( R.string.the_price_must_lower_than, - "$PERPS_USD_SYMBOL${liquidationPriceShort.stripTrailingZeros().toPlainString()}", + "$PERPS_USD_SYMBOL${liquidationBound.stripTrailingZeros().toPlainString()}", ) else -> null } @@ -1307,9 +1236,8 @@ internal fun validateTpSlPrice( private fun validateTpSlPercent( rawValue: String, currentPrice: BigDecimal, - percentBasePrice: BigDecimal, - liquidationBasePrice: BigDecimal, - leverage: Int, + calculationBasis: TpSlCalculationBasis?, + liquidationBound: BigDecimal?, isLong: Boolean, mode: PerpsTpSlBottomSheetDialogFragment.Mode, priceScale: Int, @@ -1323,23 +1251,22 @@ private fun validateTpSlPercent( if (percent <= BigDecimal.ZERO) { return MixinApplicationHolder.getString(R.string.error_percentage_must_be_greater_than_value, "0%") } + val basis = calculationBasis ?: return MixinApplicationHolder.getString(R.string.error_invalid_number) val derivedPrice = percentToPriceInput( percentMagnitudeInput = trimmed, - percentBasePrice = percentBasePrice, - leverage = leverage, + calculationBasis = basis, isLong = isLong, mode = mode, priceScale = priceScale, ) if (derivedPrice.isBlank()) { - val maxPercent = (leverage * 100).toBigDecimal().stripTrailingZeros().toPlainString() + val maxPercent = basis.zeroPriceRoiPercentCeiling().stripTrailingZeros().toPlainString() return MixinApplicationHolder.getString(R.string.error_percentage_must_be_less_than_value, "$maxPercent%") } return validateTpSlPrice( rawValue = derivedPrice, currentPrice = currentPrice, - liquidationBasePrice = liquidationBasePrice, - leverage = leverage, + liquidationBound = liquidationBound, isLong = isLong, isTakeProfit = mode == PerpsTpSlBottomSheetDialogFragment.Mode.TAKE_PROFIT, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlCalculation.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlCalculation.kt new file mode 100644 index 0000000000..cb58896077 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlCalculation.kt @@ -0,0 +1,125 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import java.math.BigDecimal +import java.math.RoundingMode + +private const val TP_SL_CALCULATION_SCALE = 16 + +internal data class TpSlCalculationBasis( + val entryPrice: BigDecimal, + val margin: BigDecimal, + val absoluteQuantity: BigDecimal, +) { + fun pnlAt(targetPrice: BigDecimal, isLong: Boolean): TpSlPnlResult? { + if (targetPrice <= BigDecimal.ZERO) return null + val direction = if (isLong) BigDecimal.ONE else BigDecimal.ONE.negate() + val signedPnl = targetPrice.subtract(entryPrice).multiply(absoluteQuantity).multiply(direction) + if (signedPnl.compareTo(BigDecimal.ZERO) == 0) return null + val signedRoiPercent = signedPnl + .multiply(BigDecimal(100)) + .divide(margin, TP_SL_CALCULATION_SCALE, RoundingMode.HALF_UP) + return TpSlPnlResult(signedPnl = signedPnl, signedRoiPercent = signedRoiPercent) + } + + fun zeroPriceRoiPercentCeiling(): BigDecimal = entryPrice + .multiply(absoluteQuantity) + .multiply(BigDecimal(100)) + .divide(margin, TP_SL_CALCULATION_SCALE, RoundingMode.HALF_UP) +} + +internal data class TpSlPnlResult( + val signedPnl: BigDecimal, + val signedRoiPercent: BigDecimal, +) + +internal fun resolveTpSlConversionBasis( + entryPrice: String, + currentPrice: String, + marginAmount: String, + positionQuantity: String?, + leverage: Int, +): TpSlCalculationBasis? = resolveTpSlCalculationBasis( + entryPrice = entryPrice, + currentPrice = currentPrice, + marginAmount = if (positionQuantity == null && + (marginAmount.isBlank() || marginAmount.toBigDecimalOrNull()?.signum() == 0) + ) "1" else marginAmount, + positionQuantity = positionQuantity, + leverage = leverage, +) + +internal fun resolveTpSlCalculationBasis( + entryPrice: String, + currentPrice: String, + marginAmount: String, + positionQuantity: String?, + leverage: Int, +): TpSlCalculationBasis? { + val margin = marginAmount.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null + val parsedEntry = entryPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + val entry = if (positionQuantity == null) { + parsedEntry ?: currentPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return null + } else { + parsedEntry ?: return null + } + val absoluteQuantity = if (positionQuantity == null) { + if (leverage <= 0) return null + margin.multiply(BigDecimal(leverage)) + .divide(entry, TP_SL_CALCULATION_SCALE, RoundingMode.HALF_UP) + .takeIf { it > BigDecimal.ZERO } + ?: return null + } else { + positionQuantity.toBigDecimalOrNull()?.abs()?.takeIf { it > BigDecimal.ZERO } ?: return null + } + return TpSlCalculationBasis(entry, margin, absoluteQuantity) +} + +internal fun targetPriceForRoiPercent( + basis: TpSlCalculationBasis, + percentMagnitudeInput: String, + isLong: Boolean, + isTakeProfit: Boolean, + priceScale: Int, +): String { + val magnitude = percentMagnitudeInput.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: return "" + val signedRoi = magnitude + .divide(BigDecimal(100), TP_SL_CALCULATION_SCALE, RoundingMode.HALF_UP) + .let { if (isTakeProfit) it else it.negate() } + val direction = if (isLong) BigDecimal.ONE else BigDecimal.ONE.negate() + val targetPrice = basis.entryPrice.add( + signedRoi.multiply(basis.margin) + .divide(basis.absoluteQuantity, TP_SL_CALCULATION_SCALE, RoundingMode.HALF_UP) + .multiply(direction) + ) + if (targetPrice <= BigDecimal.ZERO) return "" + return formatPerpsPriceInput(targetPrice, priceScale) +} + +internal fun resolveTpSlLiquidationBound( + serverLiquidationPrice: String?, + entryPrice: String, + currentPrice: String, + leverage: Int, + isLong: Boolean, +): BigDecimal? { + if (!serverLiquidationPrice.isNullOrBlank()) { + return serverLiquidationPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + } + if (leverage <= 0) return null + val base = entryPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + ?: currentPrice.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } + ?: return null + val offset = BigDecimal.ONE.divide(BigDecimal(leverage), TP_SL_CALCULATION_SCALE, RoundingMode.HALF_UP) + return base.multiply(if (isLong) BigDecimal.ONE.subtract(offset) else BigDecimal.ONE.add(offset)) + .takeIf { it > BigDecimal.ZERO } +} + +internal fun isStopBeyondLiquidation( + stopPrice: BigDecimal, + liquidationBound: BigDecimal?, + isLong: Boolean, +): Boolean = when { + liquidationBound == null -> true + isLong -> stopPrice > liquidationBound + else -> stopPrice < liquidationBound +} diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlCalculationTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlCalculationTest.kt new file mode 100644 index 0000000000..e0f22e498b --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlCalculationTest.kt @@ -0,0 +1,186 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal +import java.math.RoundingMode + +class PerpsTpSlCalculationTest { + @Test + fun existingLongUsesActualQuantityAndAddedMargin() { + val basis = existingBasis(quantity = "10", margin = "200")!! + + assertEquals("110", targetPriceForRoiPercent(basis, "50", isLong = true, isTakeProfit = true, priceScale = 2)) + assertResult(basis.pnlAt(BigDecimal("110"), isLong = true), pnl = "100", roiPercent = "50") + } + + @Test + fun existingLongUsesReducedCurrentMargin() { + val basis = existingBasis(quantity = "10", margin = "50")!! + + assertEquals("102.5", targetPriceForRoiPercent(basis, "50", isLong = true, isTakeProfit = true, priceScale = 2)) + assertResult(basis.pnlAt(BigDecimal("110"), isLong = true), pnl = "100", roiPercent = "200") + } + + @Test + fun existingShortUsesAbsoluteQuantityAndDirection() { + val basis = existingBasis(quantity = "-10", margin = "200")!! + + assertEquals("90", targetPriceForRoiPercent(basis, "50", isLong = false, isTakeProfit = true, priceScale = 2)) + assertResult(basis.pnlAt(BigDecimal("90"), isLong = false), pnl = "100", roiPercent = "50") + assertEquals("110", targetPriceForRoiPercent(basis, "50", isLong = false, isTakeProfit = false, priceScale = 2)) + assertResult(basis.pnlAt(BigDecimal("110"), isLong = false), pnl = "-100", roiPercent = "-50") + } + + @Test + fun existingShortUsesReducedCurrentMargin() { + val basis = existingBasis(quantity = "10", margin = "50")!! + + assertEquals("97.5", targetPriceForRoiPercent(basis, "50", isLong = false, isTakeProfit = true, priceScale = 2)) + assertResult(basis.pnlAt(BigDecimal("90"), isLong = false), pnl = "100", roiPercent = "200") + } + + @Test + fun existingLongStopLossKeepsSignedLoss() { + val basis = existingBasis(quantity = "10", margin = "200")!! + + assertEquals("90", targetPriceForRoiPercent(basis, "50", isLong = true, isTakeProfit = false, priceScale = 2)) + assertResult(basis.pnlAt(BigDecimal("90"), isLong = true), pnl = "-100", roiPercent = "-50") + } + + @Test + fun preOpenEstimationPreservesLeverageConversions() { + val basis = resolveTpSlCalculationBasis( + entryPrice = "100", + currentPrice = "100", + marginAmount = "100", + positionQuantity = null, + leverage = 10, + )!! + + assertDecimalEquals("10", basis.absoluteQuantity) + assertEquals("105", targetPriceForRoiPercent(basis, "50", isLong = true, isTakeProfit = true, priceScale = 2)) + assertResult(basis.pnlAt(BigDecimal("105"), isLong = true), pnl = "50", roiPercent = "50") + assertResult(basis.pnlAt(BigDecimal("95"), isLong = false), pnl = "50", roiPercent = "50") + } + + @Test + fun preOpenWithoutMarginAllowsConversionsWithoutPnlPreview() { + for (margin in listOf("", "0", "0.00")) { + val basis = resolveTpSlConversionBasis("", "100", margin, null, 10)!! + + assertEquals("105", targetPriceForRoiPercent(basis, "50", isLong = true, isTakeProfit = true, priceScale = 2)) + assertEquals("95", targetPriceForRoiPercent(basis, "50", isLong = true, isTakeProfit = false, priceScale = 2)) + assertEquals("95", targetPriceForRoiPercent(basis, "50", isLong = false, isTakeProfit = true, priceScale = 2)) + assertEquals("105", targetPriceForRoiPercent(basis, "50", isLong = false, isTakeProfit = false, priceScale = 2)) + assertDecimalEquals("50", basis.pnlAt(BigDecimal("105"), isLong = true)?.signedRoiPercent) + assertDecimalEquals("-50", basis.pnlAt(BigDecimal("105"), isLong = false)?.signedRoiPercent) + assertDecimalEquals("1000", basis.zeroPriceRoiPercentCeiling()) + assertNull(resolveTpSlCalculationBasis("", "100", margin, null, 10)) + } + } + + @Test + fun roundedSubmittedPriceDrivesPercentPreview() { + val basis = existingBasis(quantity = "3", margin = "100")!! + val submittedPrice = targetPriceForRoiPercent(basis, "10", isLong = true, isTakeProfit = true, priceScale = 2) + + assertEquals("103.33", submittedPrice) + assertResult(basis.pnlAt(BigDecimal(submittedPrice), isLong = true), pnl = "9.99", roiPercent = "9.99") + } + + @Test + fun fractionalInputsRetainPrecisionUntilPresentation() { + val basis = resolveTpSlCalculationBasis("12.34567891", "12", "7.65432109", "0.33333333", 20)!! + val result = basis.pnlAt(BigDecimal("12.98765432"), isLong = true)!! + + assertDecimalEquals("0.2139918011934153", result.signedPnl) + assertEquals("2.80", result.signedRoiPercent.setScale(2, RoundingMode.HALF_UP).toPlainString()) + } + + @Test + fun invalidExistingInputsNeverFallBackToPreOpenEstimate() { + for (quantity in listOf("", "bad", "0")) { + assertNull(resolveTpSlCalculationBasis("100", "100", "200", quantity, 10)) + assertNull(resolveTpSlConversionBasis("100", "100", "200", quantity, 10)) + } + for (margin in listOf("", "bad", "0", "-1")) { + assertNull(resolveTpSlCalculationBasis("100", "100", margin, "10", 10)) + assertNull(resolveTpSlConversionBasis("100", "100", margin, "10", 10)) + } + assertNull(resolveTpSlCalculationBasis("", "100", "200", "10", 10)) + assertNull(resolveTpSlCalculationBasis("bad", "100", "200", "10", 10)) + assertNull(resolveTpSlCalculationBasis("0", "100", "200", "10", 10)) + } + + @Test + fun invalidPreOpenInputsDoNotProduceABasis() { + assertNull(resolveTpSlCalculationBasis("0", "0", "100", null, 10)) + assertNull(resolveTpSlCalculationBasis("100", "100", "100", null, 0)) + assertNull(resolveTpSlCalculationBasis("100", "100", "-1", null, 10)) + assertNull(resolveTpSlConversionBasis("100", "100", "-1", null, 10)) + assertNull(resolveTpSlConversionBasis("100", "100", "bad", null, 10)) + } + + @Test + fun preOpenQuantityRoundedToZeroDoesNotProduceABasis() { + assertNull(resolveTpSlCalculationBasis("100000000000000000", "1", "1", null, 1)) + } + + @Test + fun zeroPriceRoiCeilingUsesActualExistingPositionBasis() { + val basis = existingBasis(quantity = "10", margin = "200")!! + + assertDecimalEquals("500", basis.zeroPriceRoiPercentCeiling()) + } + + @Test + fun invalidOrUnchangedTargetDoesNotProducePnl() { + val basis = existingBasis(quantity = "10", margin = "200")!! + + assertNull(basis.pnlAt(BigDecimal.ZERO, isLong = true)) + assertNull(basis.pnlAt(BigDecimal("100"), isLong = true)) + } + + @Test + fun validServerLiquidationIsAuthoritativeAndStrict() { + val longBound = resolveTpSlLiquidationBound("80", "100", "100", leverage = 10, isLong = true) + val longBoundAtDifferentLeverage = resolveTpSlLiquidationBound("80", "100", "100", leverage = 50, isLong = true) + val shortBound = resolveTpSlLiquidationBound("120", "100", "100", leverage = 50, isLong = false) + + assertDecimalEquals("80", longBound) + assertDecimalEquals("80", longBoundAtDifferentLeverage) + assertFalse(isStopBeyondLiquidation(BigDecimal("80"), longBound, isLong = true)) + assertFalse(isStopBeyondLiquidation(BigDecimal("79.99"), longBound, isLong = true)) + assertTrue(isStopBeyondLiquidation(BigDecimal("80.01"), longBound, isLong = true)) + assertDecimalEquals("120", shortBound) + assertFalse(isStopBeyondLiquidation(BigDecimal("120"), shortBound, isLong = false)) + assertFalse(isStopBeyondLiquidation(BigDecimal("120.01"), shortBound, isLong = false)) + assertTrue(isStopBeyondLiquidation(BigDecimal("119.99"), shortBound, isLong = false)) + } + + @Test + fun absentServerLiquidationUsesLegacyEstimateOnly() { + assertDecimalEquals("90.0", resolveTpSlLiquidationBound(null, "100", "90", 10, isLong = true)) + assertDecimalEquals("110.0", resolveTpSlLiquidationBound("", "100", "90", 10, isLong = false)) + assertNull(resolveTpSlLiquidationBound("bad", "100", "90", 10, isLong = true)) + assertNull(resolveTpSlLiquidationBound("0", "100", "90", 10, isLong = true)) + } + + private fun existingBasis(quantity: String, margin: String): TpSlCalculationBasis? = + resolveTpSlCalculationBasis("100", "100", margin, quantity, 10) + + private fun assertResult(result: TpSlPnlResult?, pnl: String, roiPercent: String) { + requireNotNull(result) + assertDecimalEquals(pnl, result.signedPnl) + assertDecimalEquals(roiPercent, result.signedRoiPercent) + } + + private fun assertDecimalEquals(expected: String, actual: BigDecimal?) { + requireNotNull(actual) + assertEquals(0, BigDecimal(expected).compareTo(actual)) + } +} From c4ca7558c63d7e7c06cce9a2607915240fd464ab Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 28 Sep 2026 16:00:44 +0800 Subject: [PATCH 28/29] fix(perps): ignore in-flight orders when computing the sync offset Restrict PerpsOrderDao.getLatestUpdatedAt() to terminal statuses (filled/rejected/closed) so processing orders no longer advance the sync cursor, label PnL as "unrealized PnL" across locales, and add a DAO test covering the behaviour. --- .../mixin/android/db/perps/PerpsOrderDao.kt | 2 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 2 +- .../web3/trade/perps/PositionDetailPage.kt | 2 +- app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-in/strings.xml | 1 + app/src/main/res/values-ja/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 3 ++- app/src/main/res/values-ms/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 3 ++- app/src/main/res/values-zh-rTW/strings.xml | 3 ++- app/src/main/res/values/strings.xml | 3 ++- .../android/db/perps/PerpsOrderDaoTest.kt | 21 +++++++++++++++++++ 13 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt index 7328e38123..5f55b159ef 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt @@ -82,7 +82,7 @@ interface PerpsOrderDao : BaseDao { @Query("DELETE FROM perps_orders") suspend fun deleteAll() - @Query("SELECT MAX(updated_at) FROM perps_orders") + @Query("SELECT MAX(updated_at) FROM perps_orders WHERE status IN ('filled', 'rejected', 'closed')") suspend fun getLatestUpdatedAt(): String? @Query("SELECT SUM(CAST(realized_pnl AS REAL)) FROM perps_orders WHERE order_type = 'close'") diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index fd9018ca7f..9d58ff1754 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -1246,7 +1246,7 @@ private fun OpenPositionCard( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.PnL).uppercase(), + text = stringResource(R.string.perps_unrealized_pnl).uppercase(), fontSize = 12.sp, lineHeight = 14.sp, style = compactTextStyle, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt index 55d46a8ac1..f916e85a5d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt @@ -269,7 +269,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.PnL), + label = stringResource(R.string.perps_unrealized_pnl), value = formatSignedFiat(pnl), valueColor = pnlColor, subtitle = formatSignedPercent(roe), diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4f909f65a0..894188b059 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1218,6 +1218,7 @@ Margen añadido Margen reducido PnL realizado + PnL no realizado ERROR 10655: El tamaño de la posición supera el límite para el apalancamiento seleccionado. Reduzca el importe o el apalancamiento. ERROR 10655: El importe de apertura supera el límite máximo de %1$s. Reduzca el importe. ERROR 10655: No se puede ampliar la posición. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 9688cb9d3e..df71665616 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -776,6 +776,7 @@ Margin Ditambahkan Margin Dikurangi PnL Terealisasi + PnL Belum Terealisasi KESALAHAN 10655: Ukuran posisi melebihi batas untuk leverage yang dipilih. Kurangi jumlah atau leverage. KESALAHAN 10655: Jumlah pembukaan posisi melebihi batas maksimum %1$s. Kurangi jumlah. KESALAHAN 10655: Tidak dapat menambah posisi. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1464c06fa5..8dbec6191b 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1164,6 +1164,7 @@ 証拠金追加 証拠金減額 実現損益 + 未実現損益 エラー 10655:ポジションサイズが選択したレバレッジの上限を超えています。金額またはレバレッジを下げてください。 エラー 10655:新規建玉の金額が上限の%1$sを超えています。金額を下げてください。 エラー 10655:建玉を追加できません。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f3f8aa91f2..b066b20c69 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2340,7 +2340,7 @@ 낮은 레버리지, 더 작고 느린 손익 레버리지가 높을수록 청산 위험이 높아집니다. 레버리지가 높으면 작은 가격 변동으로도 큰 손실이 발생할 수 있습니다. - 크기는 마진 × 레버리지로 계산된 포지션의 총 가치를 나타냅니다. + 크기는 현재 포지션의 가치를 나타내며, 포지션 수량 × 마크 가격으로 계산됩니다. 더 큰 규모, 더 큰 손익 더 작은 크기, 더 작은 손익 이익실현 및 손절매는 발동 가격에 도달하면 자동으로 실행되는 조건부 주문입니다. @@ -2700,4 +2700,5 @@ 증거금 추가 증거금 감소 실현 손익 + 미실현 손익 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 5339e01074..21f7cadfd4 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -841,6 +841,7 @@ Margin Ditambah Margin Dikurangkan PnL Direalisasikan + PnL Belum Direalisasikan RALAT 10655: Saiz posisi melebihi had untuk leveraj yang dipilih. Kurangkan jumlah atau leveraj. RALAT 10655: Amaun pembukaan posisi melebihi had maksimum %1$s. Kurangkan amaun. RALAT 10655: Posisi tidak dapat ditambah. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2ea7aab507..6e5c1b3336 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1159,6 +1159,7 @@ Маржа добавлена Маржа уменьшена Реализованная прибыль/убыток + Нереализованная прибыль/убыток ОШИБКА 10655: Размер позиции превышает лимит для выбранного кредитного плеча. Уменьшите сумму или кредитное плечо. ОШИБКА 10655: Сумма открытия позиции превышает максимальный лимит %1$s. Уменьшите сумму. ОШИБКА 10655: Невозможно увеличить позицию. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 77ba89d995..2452a2832d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2317,7 +2317,7 @@ 杠杆越低,盈亏越小且越慢 杠杆越高,爆仓风险越大 高杠杆下,小幅价格波动也可能导致较大亏损 - 当前合约的仓位规模由「金额 × 杠杆」计算得出,表示本次交易所控制的资产规模。 + 仓位规模表示当前持仓的价值,按「仓位数量 × 标记价格」计算。 仓位越大,盈亏金额越大 仓位越小,盈亏金额越小 止盈和止损是预先设定的自动平仓指令,帮助你在不盯盘的情况下管理风险。 @@ -2649,4 +2649,5 @@ 增加保证金 减少保证金 已实现盈亏 + 未实现盈亏 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 76082157f8..fa0869138f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2435,7 +2435,7 @@ 槓桿越低,盈虧越小且越慢 槓桿越高,爆倉風險越大 高槓杆下,小幅價格波動也可能導致較大虧損 - 當前合約的倉位規模由「金額 × 槓桿」計算得出,表示本次交易所控制的資產規模。 + 倉位規模表示目前持倉的價值,按「倉位數量 × 標記價格」計算。 倉位越大,盈虧金額越大 倉位越小,盈虧金額越小 止盈和止損是預先設定的自動平倉指令,幫助你在不盯盤的情況下管理風險。 @@ -2701,4 +2701,5 @@ 增加保證金 減少保證金 已實現盈虧 + 未實現盈虧 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d50ffc859..5bf8c9d1a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2398,7 +2398,7 @@ Lower leverage, smaller and slower PnL Higher leverage increases liquidation risk Under high leverage, small price movements can lead to large losses - Size represents the total value of your position, calculated as margin × leverage. + Size represents the current value of your position, calculated as position quantity × mark price. Larger size, larger PnL Smaller size, smaller PnL Take-profit and stop-loss are conditional orders that execute automatically when the trigger price is reached. @@ -2731,4 +2731,5 @@ Added Margin Reduced Margin Realized PnL + Unrealized PnL diff --git a/app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt b/app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt index 99b3244555..9eb85b2200 100644 --- a/app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt @@ -10,6 +10,7 @@ import one.mixin.android.api.response.perps.PerpsOrder import one.mixin.android.db.PerpsDatabase import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -54,6 +55,26 @@ class PerpsOrderDaoTest { assertEquals(expected, (page as PagingSource.LoadResult.Page).data.map { it.orderId }) } + @Test + fun syncOffsetIgnoresProcessingOrdersUntilTheyComplete() = runBlocking { + val dao = database.perpsOrderDao() + assertNull(dao.getLatestUpdatedAt()) + + val pending = order("05", PerpsOrder.TYPE_INCREASE_MARGIN).copy(status = PerpsOrder.STATUS_PROCESSING) + dao.insertAll(listOf(pending)) + assertNull(dao.getLatestUpdatedAt()) + + listOf(PerpsOrder.STATUS_FILLED, PerpsOrder.STATUS_REJECTED, PerpsOrder.STATUS_CLOSED).forEachIndexed { index, status -> + val completed = order("0${index + 1}", PerpsOrder.TYPE_INCREASE_MARGIN).copy(status = status) + dao.insertAll(listOf(completed)) + assertEquals(completed.updatedAt, dao.getLatestUpdatedAt()) + } + + dao.insertAll(listOf(pending.copy(status = PerpsOrder.STATUS_FILLED))) + assertEquals(pending.updatedAt, dao.getLatestUpdatedAt()) + assertEquals(pending.orderId, dao.getOrdersByMarket("market").first().orderId) + } + private fun order(id: String, type: String) = PerpsOrder( orderId = id, positionId = "position", From 62410d797ad16679debc5eb324b5dc721adc6c34 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 28 Sep 2026 16:24:52 +0800 Subject: [PATCH 29/29] fix(perps): correct input validation and refresh feedback --- .../ui/home/web3/trade/InputTextField.kt | 8 ++------ .../PerpsAddBottomSheetDialogFragment.kt | 19 ++++++++++++------- .../PerpsTpSlBottomSheetDialogFragment.kt | 4 +++- .../ui/home/web3/trade/TradeInputTest.kt | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 14 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 4c0d2a31c6..ccb1d760fa 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 @@ -53,7 +53,6 @@ import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme 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 @@ -80,6 +79,8 @@ internal fun isTradeInputDecimalAllowed( value: String, maxDecimalPlaces: Int? = TRADE_INPUT_MAX_DECIMAL_PLACES, ): Boolean { + val decimalValue = if (value.startsWith('.')) "0$value" else value + if (value.isNotBlank() && decimalValue.toBigDecimalOrNull() == null) return false maxDecimalPlaces ?: return true val decimalIndex = value.indexOf('.') if (maxDecimalPlaces == 0) return decimalIndex < 0 @@ -184,11 +185,6 @@ fun InputContent( if (!isTradeInputDecimalAllowed(it.text, maxDecimalPlaces)) { return@BasicTextField } - try { - if (it.text.isBlank()) BigDecimal.ZERO else BigDecimal(it.text) - } catch (e: Exception) { - return@BasicTextField - } textFieldValue = it onInputChanged?.invoke(it.text) }, 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 8becc5ab51..303e9670ff 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 @@ -303,9 +303,9 @@ private fun PerpsAddContent( ?.let { limitTradeInputDecimalPlaces(it, TRADE_INPUT_MAX_DECIMAL_PLACES) }.orEmpty() val liquidationState = remember(position.positionId, amount) { LiquidationPriceState() } val remoteLiquidationPrice = liquidationState.price - val isLiquidationLoading = liquidationState.isLoading - var liquidationPriceLimit by remember(position.positionId) { mutableStateOf(null) } - var liquidationError by remember(position.positionId) { mutableStateOf(null) } + var liquidationPriceLimit by remember(position.positionId, amount) { mutableStateOf(null) } + var liquidationError by remember(position.positionId, amount) { mutableStateOf(null) } + val isLiquidationLoading = liquidationState.isLoading && liquidationPriceLimit == null && liquidationError == null val hasInputAmount = amountValue != null && amountValue > BigDecimal.ZERO val minimumMargin = market?.minAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO @@ -329,22 +329,27 @@ private fun PerpsAddContent( val priceScale = market?.priceScale ?: position.priceScale LaunchedEffect(amount, belowMinimumMargin, aboveMaximumMargin, position.updatedAt, market?.last) { - liquidationPriceLimit = null - liquidationError = null if (!shouldRequestLiquidationPrice(amountValue, minimumMargin) || aboveMaximumMargin) { + liquidationPriceLimit = null + liquidationError = null liquidationState.price = null return@LaunchedEffect } liquidationState.refresh { delay(200L) + var nextLimit: LiquidationPriceLimit? = null + var nextError: String? = null requestLiquidationPrice( - onLimitExceeded = { liquidationPriceLimit = it }, - onFailure = { liquidationError = it ?: context.getString(R.string.Data_error) }, + onLimitExceeded = { nextLimit = it }, + onFailure = { nextError = it ?: context.getString(R.string.Data_error) }, ) { viewModel.estimateLiquidationPrice( amount = normalizedAmount, positionId = position.positionId, ) + }.also { + liquidationPriceLimit = nextLimit + liquidationError = nextError } } } 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 9b56d2ebfd..e2916d3beb 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 @@ -1260,7 +1260,9 @@ private fun validateTpSlPercent( priceScale = priceScale, ) if (derivedPrice.isBlank()) { - val maxPercent = basis.zeroPriceRoiPercentCeiling().stripTrailingZeros().toPlainString() + val maxPercent = basis.zeroPriceRoiPercentCeiling() + .setScale(2, RoundingMode.DOWN) + .stripTrailingZeros().toPlainString() return MixinApplicationHolder.getString(R.string.error_percentage_must_be_less_than_value, "$maxPercent%") } return validateTpSlPrice( 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 ae242aac54..8bfbc7b010 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 @@ -37,6 +37,21 @@ class TradeInputTest { assertTrue(isTradeInputDecimalAllowed("12.123456789", maxDecimalPlaces = null)) } + @Test + fun tradeAmountInputAllowsTypingLeadingDecimalPoint() { + listOf("", ".", ".5", ".", "").forEach { + assertTrue(isTradeInputDecimalAllowed(it)) + } + assertTrue(isTradeInputDecimalAllowed(".12345678")) + assertTrue(isTradeInputDecimalAllowed(".", maxDecimalPlaces = null)) + assertFalse(isTradeInputDecimalAllowed(".", maxDecimalPlaces = 0)) + assertFalse(isTradeInputDecimalAllowed(".123456789")) + listOf("..", ".x", "1.2.3").forEach { + assertFalse(isTradeInputDecimalAllowed(it)) + assertFalse(isTradeInputDecimalAllowed(it, maxDecimalPlaces = null)) + } + } + @Test fun tradeAmountInputLimitsProgrammaticValuesToEightDecimalPlaces() { assertEquals("", limitTradeInputDecimalPlaces(""))