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/api/request/perps/OpenOrderRequest.kt b/app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt index 1106d25d2a..486c9c6322 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 @@ -73,3 +73,14 @@ data class IncreaseOrderRequest( @SerializedName("leader_position_id") val leaderPositionId: 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..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 @@ -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 @@ -426,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") @@ -462,6 +464,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/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/db/perps/PerpsOrderDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsOrderDao.kt index 007c81aad8..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 @@ -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 @@ -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/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..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 @@ -1161,11 +1161,13 @@ 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) if (preview != PerpsLinkPreview.Input) { - showPerpsLinkPreview(previewMarket, openPosition, action.leaderPositionId, preview) + showPerpsLinkPreview(previewMarket, openPosition, action.leaderPositionId, preview, source) return } PerpsActivity.showOpenPosition( @@ -1223,6 +1225,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { action: PerpsOpenPositionAction, leaderPositionId: String?, preview: PerpsLinkPreview, + source: String, ) { lifecycle.withResumed { val manager = parentFragmentManager @@ -1259,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, @@ -1275,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/InputTextField.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt index 9e715b32d8..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 @@ -185,11 +186,6 @@ fun InputContent( return@BasicTextField } textFieldValue = it - try { - if (it.text.isBlank()) BigDecimal.ZERO else BigDecimal(it.text) - } catch (e: Exception) { - return@BasicTextField - } onInputChanged?.invoke(it.text) }, maxLines = 1, 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/LiquidationPriceRequest.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LiquidationPriceRequest.kt index b4fab5d11b..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,38 +1,73 @@ 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?, -) +) { + 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 data class LimitExceeded(val limit: LiquidationPriceLimit) : LiquidationPriceResult + data class MarginExceeded(val availableMargin: BigDecimal?, val message: String? = null) : LiquidationPriceResult + data object Retry : LiquidationPriceResult - data object Failure : LiquidationPriceResult + data class Failure(val message: String?) : LiquidationPriceResult } internal fun liquidationPriceResult( price: String?, errorCode: Int?, limit: LiquidationPriceLimit = LiquidationPriceLimit(null, null), + availableMargin: BigDecimal? = null, + errorMessage: String? = null, ): LiquidationPriceResult { val validPrice = price?.takeIf { it.isNotBlank() } return when { validPrice != null -> LiquidationPriceResult.Success(validPrice) + errorCode == 10653 -> LiquidationPriceResult.MarginExceeded(availableMargin, errorMessage) errorCode == ErrorHandler.PERPS_POSITION_SIZE_EXCEEDS_LEVERAGE_LIMIT -> { LiquidationPriceResult.LimitExceeded(limit) } errorCode == 500 -> LiquidationPriceResult.Retry - else -> LiquidationPriceResult.Failure + else -> LiquidationPriceResult.Failure(errorMessage) } } @@ -65,15 +100,24 @@ internal fun shouldRequestLiquidationPrice( internal suspend fun requestLiquidationPrice( retryDelayMillis: Long = 1000L, onLimitExceeded: (LiquidationPriceLimit) -> Unit = {}, + onMarginExceeded: ((BigDecimal?) -> Unit)? = null, + 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 } + is LiquidationPriceResult.MarginExceeded -> { + if (onMarginExceeded != null) onMarginExceeded(result.availableMargin) else onFailure(result.message) + 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/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt index 23e7af213e..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 @@ -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, @@ -258,33 +261,18 @@ 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 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 @@ -426,7 +414,6 @@ fun OpenPositionPage( token = currentToken?.toSwapToken(), text = usdtAmount, selectClick = { - AnalyticsTracker.trackPerpsOpenMarginSelect(currentToken?.chainName, currentToken?.symbol) availableTokens?.let(onTokenSelect) }, onInputChanged = { usdtAmount = it }, @@ -832,6 +819,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/OpenedOrderItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenedOrderItem.kt index bb90724309..84342ff569 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,15 +59,18 @@ 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 -> - 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 -> @@ -75,7 +78,7 @@ fun OpenedOrderItem( } val amountValue = if (!isFailed) { - order.payAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO + order.payAmount.toBigDecimalOrNull() } else { null } @@ -127,7 +130,13 @@ fun OpenedOrderItem( Spacer(modifier = Modifier.height(4.dp)) Text( - text = "$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, 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/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index 4051cced69..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 @@ -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,16 +415,18 @@ class PerpetualViewModel @Inject constructor( price = null, 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)) } } @@ -510,6 +514,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) + } + private var linkPreview: Deferred>? = null internal suspend fun prepareLinkPreview( @@ -556,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/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt index e08aee84c5..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 @@ -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() @@ -216,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 { @@ -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, source = source, initialMargin = initialMargin) + .showNow(supportFragmentManager, PerpsMarginBottomSheetDialogFragment.TAG) + } + } + } + } } } @@ -325,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) } @@ -341,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 22cced6cf3..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 @@ -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,14 +57,18 @@ 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.fragment.app.FragmentActivity 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 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 @@ -115,15 +122,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) } @@ -185,26 +195,34 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( @Composable override fun ComposeContent() { + val lifecycleOwner = LocalLifecycleOwner.current val viewModel = hiltViewModel() + val livePosition by remember(position.positionId) { + viewModel.observePosition(position.positionId) + }.collectAsStateWithLifecycle(initialValue = position) var selectedToken by remember { mutableStateOf(null) } var availableTokens 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) + } } } LaunchedEffect(Unit) { + AnalyticsTracker.trackPerpsAddPositionStart(requireArguments().getString(ARGS_SOURCE).orEmpty()) viewModel.loadPerpsTokens( onSuccess = { tokens -> availableTokens = tokens @@ -216,7 +234,7 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( MixinAppTheme { PerpsAddContent( - position = position, + position = livePosition ?: position, market = market, selectedToken = selectedToken, showLiquidationPrice = showLiquidationPrice, @@ -229,17 +247,17 @@ class PerpsAddBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment( perpsTokens = tokens, ).setOnAssetClick { token -> selectedToken = token - AnalyticsTracker.trackPerpsAddMarginSelect(token.chainName, token.symbol) + AnalyticsTracker.trackPerpsAddPositionTokenSelect(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() } @@ -279,12 +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 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 + 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 @@ -307,34 +328,29 @@ private fun PerpsAddContent( .ifBlank { position.entryPrice } val priceScale = market?.priceScale ?: position.priceScale - LaunchedEffect(amount, belowMinimumMargin, aboveMaximumMargin) { - liquidationPriceLimit = null - val addMargin = amount.toBigDecimalOrNull() - if (!shouldRequestLiquidationPrice(addMargin, minimumMargin) || aboveMaximumMargin) { - liquidationJob?.cancel() - remoteLiquidationPrice = null - isLiquidationLoading = false + LaunchedEffect(amount, belowMinimumMargin, aboveMaximumMargin, position.updatedAt, market?.last) { + if (!shouldRequestLiquidationPrice(amountValue, minimumMargin) || aboveMaximumMargin) { + liquidationPriceLimit = null + liquidationError = null + 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( - onLimitExceeded = { liquidationPriceLimit = it }, + var nextLimit: LiquidationPriceLimit? = null + var nextError: String? = null + requestLiquidationPrice( + onLimitExceeded = { nextLimit = it }, + onFailure = { nextError = it ?: context.getString(R.string.Data_error) }, ) { viewModel.estimateLiquidationPrice( amount = normalizedAmount, positionId = position.positionId, ) + }.also { + liquidationPriceLimit = nextLimit + liquidationError = nextError } - isLiquidationLoading = false } } @@ -344,31 +360,17 @@ 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 - else -> liquidationLimitError + else -> liquidationLimitError ?: liquidationError } + val showSizeChange = hasInputAmount && !insufficientBalance && marginLimitError == null val currentPriceText = formatPerpsPrice(currentPrice, priceScale) val defaultLiquidationPrice = position.liquidationPrice @@ -454,7 +456,7 @@ private fun PerpsAddContent( if (position.side.equals("short", ignoreCase = true)) R.string.Short else R.string.Long ) Text( - text = stringResource(R.string.add_position_title, sideText, marketSymbol), + text = stringResource(R.string.perps_add_position_title, sideText, marketSymbol), fontSize = 16.sp, lineHeight = 20.sp, fontWeight = FontWeight.W600, @@ -598,27 +600,12 @@ private fun PerpsAddContent( .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(), - ), + title = stringResource(R.string.position_size), + value = listOfNotNull( + formatTotalSizeValue(position.quantity, "0", position.leverage, currentPrice, position.tokenSymbol.orEmpty()), + if (showSizeChange) formatTotalSizeValue(position.quantity, amount, position.leverage, currentPrice, position.tokenSymbol.orEmpty()) else null, + ).joinToString(" → "), + singleLine = false, onTipClick = { showPerpsGuide(PerpetualGuideBottomSheetDialogFragment.TAB_POSITION) }, @@ -629,7 +616,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 = { @@ -806,17 +796,18 @@ private fun BottomActions( } @Composable -private fun PerpsAddInfoRow( +internal fun PerpsAddInfoRow( title: String, 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, @@ -850,11 +841,36 @@ private fun PerpsAddInfoRow( color = MixinAppTheme.colors.textAssist, ) } else { - Text( + BasicText( text = value, - fontSize = 14.sp, - color = valueColor, - textAlign = TextAlign.End, + 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 = {}, ) } } @@ -882,21 +898,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, @@ -951,13 +952,13 @@ internal fun FragmentActivity.showPerpsAddPosition( viewModel: PerpetualViewModel, position: PerpsPositionItem, market: PerpsMarket?, + source: String, initialMargin: String? = null, leaderPositionId: String? = null, onOrderCreated: () -> Unit = {}, onDismiss: () -> Unit = {}, ) { - AnalyticsTracker.trackPerpsAddStart(AnalyticsTracker.PerpsAddType.ADD_POSITION) - PerpsAddBottomSheetDialogFragment.newInstance(position, initialMargin = initialMargin) + PerpsAddBottomSheetDialogFragment.newInstance(position, source, initialMargin = initialMargin) .setOnDestroy(onDismiss) .setOnAdd { token, amount, liquidationPrice, latestMarket -> val currentMarket = latestMarket ?: market @@ -1001,6 +1002,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/PerpsAdjustBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..3168496519 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsAdjustBottomSheetDialogFragment.kt @@ -0,0 +1,199 @@ +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 +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.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 +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.tooling.preview.Preview +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.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) + } + + fun resultKey(positionId: String) = "$TAG:$positionId" + } + + override fun getTheme() = R.style.AppTheme_Dialog + + override fun getBottomSheetHeight(view: View): Int = + requireContext().screenHeight() - view.getSafeAreaInsetsTop() + + @Composable + override fun ComposeContent() { + MixinAppTheme { + PerpsAdjustContent( + onDismiss = { dismiss() }, + 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 +} + +@Composable +private fun PerpsAdjustContent( + onDismiss: () -> Unit, + onAddMargin: () -> Unit, + onReduceMargin: () -> Unit, + onAddPosition: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MixinAppTheme.colors.background) + .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 = 21.sp, + ) + listOf(R.string.perps_margin_tip, R.string.perps_position_tip, R.string.perps_low_margin_tip).forEach { tip -> + 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) + } + } + } + } + } +} + +@Composable +private fun SectionTitle(@StringRes title: Int) { + Text( + text = stringResource(title), + fontSize = 14.sp, + lineHeight = 17.sp, + color = MixinAppTheme.colors.textMinor, + modifier = Modifier.padding(start = 4.dp), + ) +} + +@Composable +private fun AdjustAction( + @StringRes title: Int, + @StringRes description: Int, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .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), + ) { + 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.textMinor, fontSize = 16.sp, lineHeight = 20.sp) + Spacer(Modifier.height(4.dp)) + 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/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index e500e39676..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 @@ -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,7 +83,11 @@ 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" private const val ARGS_MARGIN = "args_margin" private const val ARGS_LEVERAGE = "args_leverage" @@ -94,9 +99,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) @@ -125,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, @@ -146,6 +155,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,10 +321,10 @@ 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.Sending -> R.string.Sending + 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.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( @@ -363,7 +375,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 @@ -431,7 +443,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", + text = if (isReduceMargin) { + "+${formatPerpsQuantity(estimatedReceive)} ${asset.symbol}" + } else "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, fontWeight = FontWeight.W400 @@ -445,18 +459,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 +514,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(id = R.string.Retry), cancelAction = { - AnalyticsTracker.trackPerpsClosePreviewCancel() + if (isReduceMargin) AnalyticsTracker.trackPerpsReduceMarginPreviewCancel() else AnalyticsTracker.trackPerpsClosePreviewCancel() dismiss() }, confirmAction = { - AnalyticsTracker.trackPerpsClosePreviewConfirm() + if (isReduceMargin) AnalyticsTracker.trackPerpsReduceMarginPreviewConfirm() else AnalyticsTracker.trackPerpsClosePreviewConfirm() showVerifyPinThenClose() }, ) @@ -514,11 +530,11 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(id = R.string.Confirm), cancelAction = { - AnalyticsTracker.trackPerpsClosePreviewCancel() + if (isReduceMargin) AnalyticsTracker.trackPerpsReduceMarginPreviewCancel() else AnalyticsTracker.trackPerpsClosePreviewCancel() dismiss() }, confirmAction = { - AnalyticsTracker.trackPerpsClosePreviewConfirm() + if (isReduceMargin) AnalyticsTracker.trackPerpsReduceMarginPreviewConfirm() else AnalyticsTracker.trackPerpsClosePreviewConfirm() showVerifyPinThenClose() }, ) @@ -548,13 +564,37 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } private fun showVerifyPinThenClose() { + 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 { - closePosition() - }.showNow(parentFragmentManager, VerifyBottomSheetDialogFragment.TAG) + ).also(::bindPinVerification).showNow(childFragmentManager, VerifyBottomSheetDialogFragment.TAG) + } + + private fun bindPinVerification(verification: VerifyBottomSheetDialogFragment) { + verification.disableToast = true + verification.setOnPinSuccess { + if (isReduceMargin) { + confirmMargin() + } else { + closePosition() + } + } + } + + private fun confirmMargin() { + parentFragmentManager.setFragmentResult( + RESULT_MARGIN_CONFIRMED, + Bundle().apply { + putString(RESULT_POSITION_ID, positionId) + putString(RESULT_AMOUNT, requireNotNull(reduceMarginAmount)) + }, + ) + dismiss() } private fun closePosition() { @@ -575,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 a8bad3bd21..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 @@ -118,9 +118,12 @@ 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" + 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" + 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" @@ -130,6 +133,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm leverage: Int?, margin: String?, error: String, + source: String, tokenSymbol: String = market.quoteSymbol, isAddPosition: Boolean = false, position: PerpsPositionItem? = null, @@ -146,6 +150,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) @@ -169,6 +174,8 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm priceScale: Int = 2, payUrl: String?, isAddPosition: Boolean = false, + isAddMargin: Boolean = false, + marginBefore: String? = null, ): PerpsConfirmBottomSheetDialogFragment { return PerpsConfirmBottomSheetDialogFragment().withArgs { putString(ARGS_MARKET_SYMBOL, marketSymbol) @@ -185,6 +192,8 @@ 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) + putString(ARGS_MARGIN_BEFORE, marginBefore) } } } @@ -236,6 +245,9 @@ 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 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 { @@ -337,16 +349,25 @@ 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.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( @@ -415,98 +436,93 @@ 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 (leverage > 0 && amount.isNotBlank()) { - Box(modifier = Modifier.height(6.dp)) - ProfitLossInfo( - amount = amount, - leverage = leverage, - isLong = isLong - ) - } - Box(modifier = Modifier.height(20.dp)) - - if (amount.isNotBlank()) { + if (!isAddMargin) { PerpsInfoItem( - title = stringResource(R.string.Amount).uppercase(), - value = "$amount $tokenSymbol" + 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)) } - 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 (amount.isNotBlank()) { PerpsInfoItem( - title = stringResource(R.string.Take_Profit).uppercase(), - value = takeProfit, - subValueAnnotated = tpSubValue, - info = true, - guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_TP_SL, + title = stringResource(R.string.Amount).uppercase(), + 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)) } - stopLossFiatPrice?.let { stopLoss -> - val slSubValue = calculateTpSlSubValue( - targetPrice = stopLossPrice, - entryPrice = entryPrice, - leverage = leverage, - amount = amount, - isLong = isLong, - isTakeProfit = false, - ) + if (!isAddMargin) { PerpsInfoItem( - title = stringResource(R.string.Stop_Loss).uppercase(), - value = stopLoss, - subValueAnnotated = slSubValue, - info = true, - guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_TP_SL, + title = stringResource(R.string.Entry_Price).uppercase(), + value = entryFiatPrice ) Box(modifier = Modifier.height(20.dp)) - } - if (orderError == null || rawLiquidationPrice != null) { - val lossPercent = remember(leverage) { - val percent = String.format("%.2f", 100.0 / leverage) - Timber.d("LossPercent - leverage: $leverage, lossPercent: $percent") - percent + 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)) } - val lossSubValue = if (isLong) { - val text = stringResource( - R.string.Price_Down_Loss, - lossPercent, - amount, - tokenSymbol + stopLossFiatPrice?.let { stopLoss -> + val slSubValue = calculateTpSlSubValue( + targetPrice = stopLossPrice, + entryPrice = entryPrice, + leverage = leverage, + amount = amount, + isLong = isLong, + isTakeProfit = false, ) - 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 + PerpsInfoItem( + title = stringResource(R.string.Stop_Loss).uppercase(), + value = stopLoss, + subValueAnnotated = slSubValue, + info = true, + guideTab = PerpetualGuideBottomSheetDialogFragment.TAB_TP_SL, ) - Timber.d("LossSubValue (Short) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") - text + Box(modifier = Modifier.height(20.dp)) + } + } + + if (orderError == null || rawLiquidationPrice != null) { + val lossPercent = if (isAddMargin) { + marginLiquidationLossPercent(entryPrice, rawLiquidationPrice)?.let { formatPerpsQuantity(it) } + } else { + 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( @@ -639,6 +655,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) }) @@ -889,24 +906,30 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } private fun trackPreviewConfirm() { - if (isAddPosition) { - AnalyticsTracker.trackPerpsAddPreviewConfirm() + if (isAddMargin) { + AnalyticsTracker.trackPerpsAddMarginPreviewConfirm() + } else if (isAddPosition) { + AnalyticsTracker.trackPerpsAddPositionPreviewConfirm() } else { AnalyticsTracker.trackPerpsOpenPreviewConfirm() } } private fun trackPreviewCancel() { - if (isAddPosition) { - AnalyticsTracker.trackPerpsAddPreviewCancel() + if (isAddMargin) { + AnalyticsTracker.trackPerpsAddMarginPreviewCancel() + } else if (isAddPosition) { + AnalyticsTracker.trackPerpsAddPositionPreviewCancel() } else { AnalyticsTracker.trackPerpsOpenPreviewCancel() } } private fun trackPositionSuccess() { - if (isAddPosition) { - 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/PerpsMarginAmount.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt new file mode 100644 index 0000000000..716466117d --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmount.kt @@ -0,0 +1,85 @@ +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) "${PERPS_USD_SYMBOL}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(2, RoundingMode.DOWN) + return "$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 + 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).stripTrailingZeros().toPlainString() + +internal fun maximumMarginReductionInput(currentMargin: String?, maximumReduction: BigDecimal, isPercentage: Boolean): String = + formatMarginAdjustmentInput( + if (isPercentage) marginReductionPercentage(currentMargin, maximumReduction) ?: BigDecimal.ZERO else maximumReduction, + 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 + 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? { + 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 + 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..6b0c9daca6 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginBottomSheetDialogFragment.kt @@ -0,0 +1,927 @@ +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.Box +import androidx.compose.foundation.layout.Column +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.heightIn +import androidx.compose.foundation.layout.imePadding +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.DisposableEffect +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.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.layout.Layout +import androidx.compose.ui.platform.LocalDensity +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.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.text.style.TextOverflow +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 +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.putString +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.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 +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 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, source: String, initialMargin: String? = null) = PerpsMarginBottomSheetDialogFragment().withArgs { + putParcelable(ARGS_POSITION, position) + putBoolean(ARGS_INCREASE, increase) + putString(ARGS_INITIAL_MARGIN, initialMargin) + putString(ARGS_SOURCE, source) + } + } + + 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() + + @Suppress("DEPRECATION") + 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(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) } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + var selectedToken by remember { mutableStateOf(null) } + 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) } + 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 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) } + 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?.setScale(0, RoundingMode.HALF_UP)?.toPlainString().orEmpty() + else -> amountValue?.let { formatMarginAdjustmentInput(it, false) }.orEmpty() + } + 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 && tokenBalance != null && !insufficientBalance + } else { + availableMargin?.let { amountValue != null && amountValue <= it } ?: true + } + + LaunchedEffect(normalizedAmount, increase, currentPosition.updatedAt, exceedsReductionLimit) { + liquidationError = null + liquidationPriceLimit = null + if (totalMargin == null || exceedsReductionLimit) { + liquidationState.price = null + return@LaunchedEffect + } + liquidationState.refresh { + delay(200L) + requestLiquidationPrice( + onFailure = { liquidationError = it ?: getString(R.string.Data_error) }, + onLimitExceeded = { liquidationPriceLimit = it }, + onMarginExceeded = if (increase) null else { available -> + availableMargin = available + liquidationError = if (available == null) getString(R.string.Data_error) else null + }, + ) { + viewModel.estimateLiquidationPrice( + amount = normalizedAmount, + positionId = currentPosition.positionId, + action = if (increase) "increase_margin" else "decrease_margin", + ) + } + } + } + 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) { + val source = requireArguments().getString(ARGS_SOURCE).orEmpty() + if (increase) { + AnalyticsTracker.trackPerpsAddMarginStart(source) + viewModel.loadPerpsTokens( + onSuccess = { tokens -> + availableTokens = tokens + selectedToken = tokens.firstOrNull() + }, + onError = { error = it }, + ) + } else { + AnalyticsTracker.trackPerpsReduceMarginStart(source) + } + } + + 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 + 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 + 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 = market?.last ?: currentPosition.entryPrice, + marginAssetPrice = token.priceUsd, + tokenSymbol = token.symbol, + liquidationPrice = estimatedLiquidationPrice, + priceScale = market?.priceScale ?: currentPosition.priceScale, + payUrl = data.paymentUrl, + isAddMargin = true, + marginBefore = currentPosition.margin, + ).show(parentFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + } else { + AnalyticsTracker.trackPerpsReduceMarginEnd() + toast(R.string.perps_margin_submitted) + viewModel.refreshSinglePosition(currentPosition.positionId, currentPosition.walletId) + viewModel.refreshOrders(currentPosition.walletId) + } + dismiss() + } else if (!increase && 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) + 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 = formatPerpsMarginLimit(currentPosition.margin, maximumReduction, reduceByPercent) + stringResource(R.string.max_removable, limit) + } else null + val errorText = if (insufficientBalance) stringResource(R.string.insufficient_balance) else + error ?: liquidationPriceLimit?.errorMessage(requireContext(), selectedToken?.symbol.orEmpty()) ?: liquidationError + val onSubmit = { + if (increase) { + submit(normalizedAmount) + } else if (canSubmit && childFragmentManager.findFragmentByTag(PerpsCloseBottomSheetDialogFragment.TAG) == null) { + keyboardController?.hide() + focusManager.clearFocus() + val approvedAmount = normalizedAmount + AnalyticsTracker.trackPerpsReduceMarginPreview() + PerpsCloseBottomSheetDialogFragment.newInstance(currentPosition.toPosition(), reduceMarginAmount = approvedAmount) + .show(childFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + } + } + PerpsMarginContent( + position = currentPosition, + marketPrice = market?.last, + priceScale = market?.priceScale ?: currentPosition.priceScale, + increase = increase, + totalMargin = totalMargin?.takeIf { errorText == null && !exceedsReductionLimit }, + estimatedLiquidationPrice = remoteLiquidationPrice?.takeIf { errorText == null && !exceedsReductionLimit }, + isLiquidationLoading = isLiquidationLoading, + errorText = errorText, + canSubmit = canSubmit, + loading = loading, + onCancel = { + if (increase) AnalyticsTracker.trackPerpsAddMarginCancel() else AnalyticsTracker.trackPerpsReduceMarginCancel() + 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, + ) + } + Spacer(Modifier.height(8.dp)) + InputContent( + token = selectedToken?.toSwapToken(), + text = amount, + 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 + AnalyticsTracker.trackPerpsAddMarginTokenSelect(token.chainName, token.symbol) + } + }.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, + modifier = Modifier.clickable(enabled = !loading && tokenBalance != null) { + tokenBalance?.let { changeAmount(limitTradeInputDecimalPlaces(it.stripTrailingZeros().toPlainString())) } + }, + ) + 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 ?: errorText, + enabled = !loading, + onInputChanged = ::changeAmount, + onApplyReductionLimit = maximumReduction?.takeIf { reductionLimitError != null }?.let { maximum -> + { + 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() + 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?, + estimatedLiquidationPrice: String?, + isLiquidationLoading: Boolean, + 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) + 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, + ) + } + 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(8.dp)) + } + PerpsMarginActions(increase, actionTitle, errorText, canSubmit, loading, onCancel = onCancel, onSubmit = onSubmit) + } +} + +@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)) { + 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), + ) { + 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, + onApplyReductionLimit: (() -> Unit)? = null, +) { + 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 + 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.marketRed + inputValue <= BigDecimal.ZERO -> MixinAppTheme.colors.textRemarks + else -> MixinAppTheme.colors.textPrimary + } + 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) { + 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 + updateInput(formatMarginAdjustmentInput(selected, isPercentage)) + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(start = 16.dp, top = 40.dp, end = 16.dp, bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + 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, fill = false), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + if (!isPercentage) Text(PERPS_USD_SYMBOL, style = inputTextStyle) + BasicTextField( + value = textFieldValue, + onValueChange = { 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, + 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 -> + Box(contentAlignment = Alignment.Center) { + if (input.isEmpty()) Text(placeholder, style = inputTextStyle) + field() + } + }, + ) + if (isPercentage) Text("%", style = inputTextStyle) + } + IconButton( + 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)) + } + } + Spacer(Modifier.height(6.dp)) + Row( + modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(enabled = enabled, onClick = onToggleMode).padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (isPercentage) formatPerpsMarginAmount(amount) else "${percentage?.setScale(0, RoundingMode.HALF_UP)?.toPlainString() ?: "0"}%", + color = MixinAppTheme.colors.textRemarks, + fontSize = 16.sp, + lineHeight = 20.sp, + ) + Icon( + painterResource(R.drawable.ic_switch), + stringResource(if (isPercentage) R.string.Amount else R.string.perps_percentage), + tint = MixinAppTheme.colors.textRemarks, + modifier = Modifier.size(30.dp), + ) + } + Spacer(Modifier.height(12.dp)) + Box( + 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, + ) { + Text( + text = errorText.orEmpty(), + color = MixinAppTheme.colors.badgeRed, + fontSize = 12.sp, + lineHeight = 16.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(28.dp)) + Slider( + value = sliderPercentage, + onValueChange = { selectPercentage(BigDecimal(snapMarginReductionPercentage(it))) }, + enabled = enabled && margin != null && margin > BigDecimal.ZERO, + valueRange = 0f..100f, + steps = 99, + modifier = Modifier.fillMaxWidth().height(32.dp).drawWithContent { + drawContent() + val trackInset = 10.dp.toPx() + 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), + ) + } + } + }, + colors = SliderDefaults.colors( + thumbColor = MixinAppTheme.colors.accent, + activeTrackColor = MixinAppTheme.colors.accent, + inactiveTrackColor = MixinAppTheme.colors.backgroundGrayLight, + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent, + ), + ) + Layout(modifier = Modifier.fillMaxWidth(), content = { + 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 = 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) { + selectPercentage(BigDecimal(value)) + }.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 -> trackEdge + labels.lastIndex -> width - trackEdge - label.width + else -> center - label.width / 2 + } + label.placeRelative(x, 0) + } + } + } + } +} + +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() { + 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, + estimatedLiquidationPrice = null, + isLiquidationLoading = false, + 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/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 aa89e72a45..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 @@ -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_ADJUST, + ).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_ADJUST, + 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") @@ -398,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 @@ -580,14 +619,10 @@ fun PerpsMarketDetailPage( enabled = !isAddingProcessing, onClick = { if (isAddingProcessing) return@MixinButton - isAddingProcessing = true - val activity = context as? FragmentActivity ?: run { isAddingProcessing = false; return@MixinButton } - activity.showPerpsAddPosition( - viewModel = viewModel, - position = currentPosition, - market = market, - onDismiss = { isAddingProcessing = false }, - ) + val activity = context as? FragmentActivity ?: return@MixinButton + if (activity.supportFragmentManager.findFragmentByTag(PerpsAdjustBottomSheetDialogFragment.TAG) != null) return@MixinButton + PerpsAdjustBottomSheetDialogFragment.newInstance(currentPosition.positionId) + .show(activity.supportFragmentManager, PerpsAdjustBottomSheetDialogFragment.TAG) }, backgroundColor = MixinAppTheme.colors.walletGreen, contentColor = Color.White, @@ -595,7 +630,7 @@ fun PerpsMarketDetailPage( ) { Text( fontSize = 16.sp, - text = stringResource(R.string.add_position), + text = stringResource(R.string.perps_adjust), ) } @@ -1137,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) { @@ -1210,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/PerpsTpSlBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsTpSlBottomSheetDialogFragment.kt index 013ebc346f..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 @@ -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,24 @@ 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() + .setScale(2, RoundingMode.DOWN) + .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/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..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), @@ -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) ) } @@ -686,12 +686,17 @@ 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 && 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 -> @@ -708,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, @@ -756,13 +769,17 @@ fun OpenedOrderDetailPage( verticalAlignment = Alignment.Bottom, ) { Text( - text = 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 { it.isNotBlank() } + val symbol = openedOrder.tokenSymbol?.takeIf { !isMargin && it.isNotBlank() } if (symbol != null) { Spacer(modifier = Modifier.width(4.dp)) Text( @@ -842,31 +859,33 @@ fun OpenedOrderDetailPage( Spacer(modifier = Modifier.height(20.dp)) if (!isFailed) { - PositionDetailItem( - label = stringResource(R.string.Entry_Price).uppercase(), - value = formatPerpsPrice(openedOrder.entryPrice, openedOrder.priceScale) - ) + if (!isMargin) { + 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)) + + PositionDetailItem( + label = stringResource(R.string.Fee).uppercase(), + value = formatPerpsSignedExactUsdDecimal(fee.negate()), + onTipClick = onFeeTipClick, + ) + } - val fee = openedOrder.feeAmount.toBigDecimalOrNull()?.abs() ?: BigDecimal.ZERO - if (fee.signum() != 0) { Spacer(modifier = Modifier.height(20.dp)) + } + amountText?.let { amount -> PositionDetailItem( - label = stringResource(R.string.Fee).uppercase(), - value = formatPerpsSignedExactUsdDecimal(fee.negate()), - onTipClick = onFeeTipClick, + label = stringResource(R.string.Amount).uppercase(), + value = amount ) + Spacer(modifier = Modifier.height(20.dp)) } - - 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)) } ItemWalletContent( @@ -878,7 +897,7 @@ fun OpenedOrderDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.Open_Time).uppercase(), + label = stringResource(R.string.Date).uppercase(), value = formatDate(openedOrder.createdAt) ) } 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/main/java/one/mixin/android/ui/wallet/InputFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/InputFragment.kt index 5d8e8a91b6..93b380b9fe 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 @@ -1649,6 +1650,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 } 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 cb53e0c2a0..7ba39d1a46 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 @@ -936,7 +936,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/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..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 @@ -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,10 +16,14 @@ 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.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 @@ -42,7 +47,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 +64,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 +86,7 @@ internal fun WalletHomeAccountCards( ) CompactEarnAccountCard( accounts = state.earnAccounts, + quoteColorReversed = state.quoteColorReversed, callbacks = callbacks, modifier = Modifier .weight(1f) @@ -96,11 +103,10 @@ 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), + contentPadding = PaddingValues(16.dp), ) { Text( text = stringResource(R.string.cash_balance), @@ -109,32 +115,29 @@ private fun CompactCashAccountCard( lineHeight = 14.sp, fontWeight = FontWeight.W400, style = AccountTextStyle, + modifier = Modifier.heightIn(min = 16.dp).wrapContentHeight(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) - 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) { + Spacer(modifier = Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { account.apyText?.let { apyText -> - Text( + AccountApy( 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), + quoteColorReversed = quoteColorReversed, + modifier = Modifier.weight(1f, fill = false), ) - } - Spacer(modifier = Modifier.weight(1f)) + } ?: Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(4.dp)) AccountArrow() } } @@ -143,6 +146,7 @@ private fun CompactCashAccountCard( @Composable private fun CompactEarnAccountCard( accounts: List, + quoteColorReversed: Boolean, callbacks: WalletHomeCallbacks, modifier: Modifier, ) { @@ -150,27 +154,46 @@ private fun CompactEarnAccountCard( AccountCardSurface( modifier = modifier .clickable { callbacks.onEarnAccountClicked() }, - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + contentPadding = PaddingValues(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, - ) - Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 16.dp), + 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), + ) + Spacer(modifier = Modifier.width(4.dp)) + EarnTokenIcons(accounts, modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + } + Spacer(modifier = Modifier.height(4.dp)) WalletHomeAccountBalance( balanceAmountText = account.balanceAmountText, modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.height(10.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - EarnTokenIcons(accounts) - Spacer(modifier = Modifier.weight(1f)) + 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() } } @@ -179,13 +202,14 @@ private fun CompactEarnAccountCard( @Composable private fun EarnAccountCard( accounts: List, + quoteColorReversed: Boolean, callbacks: WalletHomeCallbacks, modifier: Modifier, ) { 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(), @@ -196,36 +220,44 @@ 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, ) { Text( text = stringResource(R.string.earn_balance), - color = MixinAppTheme.colors.textMinor, - fontSize = 14.sp, - lineHeight = 17.sp, + color = MixinAppTheme.colors.textAssist, + fontSize = 12.sp, + lineHeight = 14.sp, fontWeight = FontWeight.W400, style = AccountTextStyle, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + 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, + ) + } } } } @@ -238,7 +270,7 @@ internal fun WalletHomeAccountBalance( modifier: Modifier = Modifier, ) { Row( - modifier = modifier, + modifier = modifier.heightIn(min = 22.dp), verticalAlignment = Alignment.Bottom, ) { BasicText( @@ -249,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, @@ -275,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, @@ -302,28 +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, + ) + } } } } - -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() }, - ) -} 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, ) } } 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/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 9be9a61fdc..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 @@ -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,14 @@ 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_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" const val PERPS_HOME_LIST = "perps_home_list" @@ -707,8 +711,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,42 +753,96 @@ object AnalyticsTracker { } } - fun trackPerpsAddStart(type: String) { - logEvent("trade_perps_add_start") { - putString("type", type) + fun trackPerpsAddPositionStart(source: String) { + logEvent("trade_perps_add_position_start") { + putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_ADJUST)) } } - fun trackPerpsAddMarginSelect(chain: String?, assetSymbol: String?) { - logEvent("trade_perps_add_margin_select") { + fun trackPerpsAddPositionTokenSelect(chain: String?, assetSymbol: String?) { + logEvent("trade_perps_add_position_token_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(source: String) { + logEvent("trade_perps_add_margin_start") { + putString("source", AnalyticsRules.perpsAdjustmentSource(source, PerpsSource.PERPS_MARKET_DETAIL_ADJUST)) + } + } + + fun trackPerpsAddMarginTokenSelect(chain: String?, assetSymbol: String?) { + logEvent("trade_perps_add_margin_token_select") { + putString("chain", chain) + putString("asset_symbol", assetSymbol) + } + } + + fun trackPerpsAddMarginPreview() { + logEvent("trade_perps_add_margin_preview") + } + + fun trackPerpsAddMarginPreviewConfirm() { + logEvent("trade_perps_add_margin_preview_confirm") + } + + fun trackPerpsAddMarginPreviewCancel() { + logEvent("trade_perps_add_margin_preview_cancel") + } + + fun trackPerpsAddMarginEnd() { + logEvent("trade_perps_add_margin_end") + } + + fun trackPerpsAddMarginCancel() { + 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_ADJUST)) + } } - fun trackPerpsAddPreviewConfirm() { - logEvent("trade_perps_add_preview_confirm") + fun trackPerpsReduceMarginPreview() { + logEvent("trade_perps_reduce_margin_preview") } - fun trackPerpsAddPreviewCancel() { - logEvent("trade_perps_add_preview_cancel") + fun trackPerpsReduceMarginPreviewConfirm() { + logEvent("trade_perps_reduce_margin_preview_confirm") } - fun trackPerpsAddEnd() { - logEvent("trade_perps_add_end") + fun trackPerpsReduceMarginPreviewCancel() { + logEvent("trade_perps_reduce_margin_preview_cancel") } - fun trackPerpsAddCancel() { - logEvent("trade_perps_add_cancel") + fun trackPerpsReduceMarginCancel() { + logEvent("trade_perps_reduce_margin_cancel") } - object PerpsAddType { - const val ADD_POSITION = "add_position" - const val ADD_MARGIN = "add_margin" + fun trackPerpsReduceMarginEnd() { + logEvent("trade_perps_reduce_margin_end") } fun trackPerpsCloseStart(type: String) { 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_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/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 19c52974a2..894188b059 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$d: 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 @@ -1179,5 +1182,46 @@ 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 + 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 + 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 + Posición aumentada + 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. + 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 dc69bcf107..df71665616 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$d: 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 @@ -737,5 +740,46 @@ 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 + 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 + 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 + Posisi Ditambahkan + 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. + 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 5b1dbdb6cf..8dbec6191b 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$d:ポジションサイズが小さすぎます。 + エラー 10650: マージンが少なすぎます + エラー 10650: マージンは最低 %1$s 必要です + エラー 10654: ポジション価値が小さすぎます + エラー 10654: ポジション価値は最低 %1$s 必要です エラー 20110:無効な電話番号です エラー 10003:SMSの送信に失敗しました 期限切れ @@ -1125,5 +1128,46 @@ Earnアカウント Earn残高 昨日の収益 + 調整 + 証拠金またはポジションを調整 + 証拠金を追加 + 証拠金を減らす + ポジションを追加 + ポジションを減らす + 証拠金を追加して強制決済リスクを下げ、ポジションの安全性を高めます + ポジションを維持しながら余剰証拠金を引き出し、資金を確保します + ポジションを増やし、潜在的な利益と損失を拡大します + ポジションを一部決済して利益を確定、または損失を抑えます + 証拠金を追加?それともポジションを増やす? + 証拠金の追加は強制決済リスクを下げます + ポジションを増やすと利益とリスクが拡大します + 証拠金が不足している場合は、先に証拠金を追加してください + 証拠金合計 + 証拠金の調整を送信しました + 証拠金の追加を確認 + 証拠金を追加中 + 証拠金の追加に失敗しました + 証拠金の減少を確認 + 証拠金を減らしています + 証拠金の減少に失敗しました + %1$s %2$s に証拠金を追加 + %1$s %2$s の証拠金を減らす + 減らす + 割合 + 証拠金の減額を確認 マーク価格(デバッグ) + 最大 %1$s APY + %1$s %2$s にポジションを追加 + %1$s %2$s のポジションを減らす + 最大減少量 %1$s + ポジション追加 + 証拠金追加 + 証拠金減額 + 実現損益 + 未実現損益 + エラー 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 49dfe209ed..b066b20c69 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -484,10 +484,16 @@ 오류 10614: 입력 금액이 너무 작거나 너무 큽니다. 금액을 조정하세요. 오류 10614: 금액이 %1$s의 최대 허용 값을 초과했습니다. 금액을 조정해주세요. 지정가 주문을 해보시겠어요? 금액이나 토큰에 제한이 없습니다. 오류 10615: 이 거래 쌍은 현재 지원되지 않습니다. 다른 토큰으로 전환해 보십시오. - 오류 %1$d: 포지션 크기가 너무 작습니다. + 오류 10650: 마진이 너무 적습니다 + 오류 10650: 마진은 최소 %1$s 이상이어야 합니다 + 오류 10654: 포지션 가치가 너무 작습니다 + 오류 10654: 포지션 가치는 최소 %1$s 이상이어야 합니다 오류 10651: 이미 오픈 포지션이 있습니다. 오류 10652: 현재 시장에서 아직 다른 주문을 처리 중입니다. 나중에 다시 시도하십시오. 오류 10655: 포지션 크기가 선택한 레버리지 한도를 초과했습니다. 금액 또는 레버리지를 낮추세요. + 오류 10655: 포지션 개설 금액이 최대 한도 %1$s을(를) 초과했습니다. 금액을 낮추세요. + 오류 10655: 포지션을 추가할 수 없습니다. + 오류 10655: 포지션 개설 금액이 최대 한도 %1$s을(를) 초과했습니다. 금액 또는 레버리지를 낮추세요. 오류 20114: 만료된 전화 확인 코드 오류 20113: 잘못된 전화 확인 코드 5회 이상 시도하셨습니다. 다시 시도하려면 최소 24시간 정도 기다려 주십시오. @@ -1650,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입니다. 승인 대출 굽기 @@ -2336,7 +2340,7 @@ 낮은 레버리지, 더 작고 느린 손익 레버리지가 높을수록 청산 위험이 높아집니다. 레버리지가 높으면 작은 가격 변동으로도 큰 손실이 발생할 수 있습니다. - 크기는 마진 × 레버리지로 계산된 포지션의 총 가치를 나타냅니다. + 크기는 현재 포지션의 가치를 나타내며, 포지션 수량 × 마크 가격으로 계산됩니다. 더 큰 규모, 더 큰 손익 더 작은 크기, 더 작은 손익 이익실현 및 손절매는 발동 가격에 도달하면 자동으로 실행되는 조건부 주문입니다. @@ -2661,4 +2665,40 @@ Earn 계정 Earn 잔액 어제 수익 + 조정 + 증거금 또는 포지션 조정 + 증거금 추가 + 증거금 감소 + 포지션 추가 + 포지션 축소 + 증거금을 추가하여 강제 청산 위험을 낮추고 포지션을 더 안전하게 유지하세요 + 포지션을 유지하면서 여유 증거금을 인출하여 자금을 확보하세요 + 포지션을 늘려 잠재 수익과 손실을 확대하세요 + 포지션을 일부 청산하여 수익을 확보하거나 손실을 줄이세요 + 증거금 추가와 포지션 추가 중 무엇을 선택할까요? + 증거금을 추가하면 강제 청산 위험이 줄어듭니다 + 포지션을 늘리면 수익과 위험이 커집니다 + 증거금이 부족하면 먼저 증거금을 추가하세요 + 총 증거금 + 증거금 조정이 제출되었습니다 + 증거금 추가 확인 + 증거금 추가 중 + 증거금 추가 실패 + 증거금 감소 확인 + 증거금 감소 중 + 증거금 감소 실패 + %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 7bed345a41..21f7cadfd4 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$d: 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 @@ -802,5 +805,46 @@ 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 + 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 + 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 + Posisi Ditambah + 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. + 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 0640cfbcea..6e5c1b3336 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$d: Размер позиции слишком мал. + ОШИБКА 10650: Сумма маржи слишком мала + ОШИБКА 10650: Сумма маржи должна составлять не менее %1$s + ОШИБКА 10654: Стоимость позиции слишком мала + ОШИБКА 10654: Стоимость позиции должна составлять не менее %1$s ОШИБКА 20110: Неверный номер телефона ОШИБКА 10003: Не удалось доставить SMS ОШИБКА 20114: Срок действия кода подтверждения телефона истек @@ -1120,5 +1123,46 @@ Счёт Earn Баланс Earn Доход за вчера + Изменить + Изменить маржу или позицию + Добавить маржу + Уменьшить маржу + Увеличить позицию + Уменьшить позицию + Добавьте маржу, чтобы снизить риск ликвидации и защитить позицию + Выведите избыточную маржу, чтобы освободить средства, сохранив позицию + Увеличьте позицию, чтобы увеличить потенциальную прибыль и убытки + Частично закройте позицию, чтобы зафиксировать прибыль или ограничить убытки + Добавить маржу или увеличить позицию? + Добавление маржи снижает риск ликвидации + Увеличение позиции повышает прибыль и риски + Если маржи мало, сначала добавьте маржу + Общая маржа + Запрос на изменение маржи отправлен + Подтвердить добавление маржи + Добавление маржи + Не удалось добавить маржу + Подтвердить уменьшение маржи + Уменьшение маржи + Не удалось уменьшить маржу + Добавить маржу в %1$s %2$s + Уменьшить маржу для %1$s %2$s + Уменьшить + Процент + Подтвердить уменьшение маржи Цена маркировки (отладка) + До %1$s APY + Увеличить позицию в %1$s %2$s + Уменьшить позицию для %1$s %2$s + Максимально можно уменьшить на %1$s + Позиция увеличена + Маржа добавлена + Маржа уменьшена + Реализованная прибыль/убыток + Нереализованная прибыль/убыток + ОШИБКА 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 888107dca1..2452a2832d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -480,11 +480,16 @@ 错误 10614: 输入金额太小或太大,请重新输入。 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 - 错误 %1$d: 仓位规模太小,请调整后重试。 - 错误 %1$d:订单价值至少为 %2$s USD,请调整后重试。 + 错误 10650:保证金太少 + 错误 10650:保证金至少 %1$s + 错误 10654:仓位价值太少 + 错误 10654:仓位价值至少 %1$s 错误 10651:当前已有持仓 当前市场已开仓。 错误 10655:仓位规模超过当前杠杆档位限制,请降低金额或杠杆。 + 错误 10655:开仓金额超出最大限额 %1$s,请减少金额。 + 错误 10655:无法加仓。 + 错误 10655:开仓金额超出最大限额 %1$s,请减少金额或降低杠杆。 错误 10656:跟单目标仓位无效,请重新选择跟单仓位。 错误 20114:验证码已过期 错误 20113:验证码错误 @@ -1627,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 授权 借款 @@ -2314,7 +2317,7 @@ 杠杆越低,盈亏越小且越慢 杠杆越高,爆仓风险越大 高杠杆下,小幅价格波动也可能导致较大亏损 - 当前合约的仓位规模由「金额 × 杠杆」计算得出,表示本次交易所控制的资产规模。 + 仓位规模表示当前持仓的价值,按「仓位数量 × 标记价格」计算。 仓位越大,盈亏金额越大 仓位越小,盈亏金额越小 止盈和止损是预先设定的自动平仓指令,帮助你在不盯盘的情况下管理风险。 @@ -2611,4 +2614,40 @@ 总收益 昨日收益 收益率 + 调整 + 调整保证金或仓位 + 添加保证金 + 减少保证金 + 增加仓位 + 减少仓位 + 增加保证金,降低强平风险,让持仓更安全 + 减少多余保证金,在保持持仓的同时释放资金 + 增加仓位,放大潜在收益与亏损 + 部分平仓,锁定收益或减少亏损 + 添加保证金还是增加仓位? + 添加保证金可降低强平风险 + 加仓会放大收益和风险 + 保证金不足时,优先添加保证金 + 总保证金 + 保证金调整已提交 + 确认添加保证金 + 正在添加保证金 + 添加保证金失败 + 确认减少保证金 + 正在减少保证金 + 减少保证金失败 + %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 cbf6cab4ff..fa0869138f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1307,10 +1307,16 @@ 錯誤 10614: 輸入金額太小或太大,請重新輸入。 錯誤 10614: 金額超出最大下單金額 %1$s,請重新輸入。試試專業模式?不受金額和幣種限制。 錯誤10615:目前不支援此交易對,請嘗試切換到其他代幣。 - 錯誤 %1$d:部位規模太小。 + 錯誤 10650:保證金太少 + 錯誤 10650:保證金至少 %1$s + 錯誤 10654:倉位價值太少 + 錯誤 10654:倉位價值至少 %1$s 錯誤 10651:當前已有持倉 錯誤10652:目前市場仍在處理其他訂單,請稍後重試。 - 錯誤 10655:部位規模超過目前槓桿檔位限制,請降低金額或槓桿。 + 錯誤 10655:倉位規模超過當前槓桿檔位限制,請降低金額或槓桿。 + 錯誤 10655:開倉金額超出最大限額 %1$s,請減少金額。 + 錯誤 10655:無法加倉。 + 錯誤 10655:開倉金額超出最大限額 %1$s,請減少金額或降低槓桿。 錯誤 10633:地址不可用 錯誤 10730:無效的邀請碼 錯誤 10731:當前賬號已繫結邀請碼,無法修改繫結。 @@ -1773,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 批准 借款 燃燒 @@ -2431,7 +2435,7 @@ 槓桿越低,盈虧越小且越慢 槓桿越高,爆倉風險越大 高槓杆下,小幅價格波動也可能導致較大虧損 - 當前合約的倉位規模由「金額 × 槓桿」計算得出,表示本次交易所控制的資產規模。 + 倉位規模表示目前持倉的價值,按「倉位數量 × 標記價格」計算。 倉位越大,盈虧金額越大 倉位越小,盈虧金額越小 止盈和止損是預先設定的自動平倉指令,幫助你在不盯盤的情況下管理風險。 @@ -2662,4 +2666,40 @@ 總收益 昨日收益 收益率 + 調整 + 調整保證金或倉位 + 新增保證金 + 減少保證金 + 增加倉位 + 減少倉位 + 增加保證金,降低強平風險,讓持倉更安全 + 減少多餘保證金,在保持持倉的同時釋放資金 + 增加倉位,放大潛在收益與虧損 + 部分平倉,鎖定收益或減少虧損 + 新增保證金還是增加倉位? + 新增保證金可降低強平風險 + 加倉會放大收益和風險 + 保證金不足時,優先新增保證金 + 總保證金 + 保證金調整已提交 + 確認新增保證金 + 正在新增保證金 + 新增保證金失敗 + 確認減少保證金 + 正在減少保證金 + 減少保證金失敗 + %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 48bc8846b8..5bf8c9d1a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -492,11 +492,16 @@ 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$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. + 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 @@ -1682,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 @@ -2395,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. @@ -2693,4 +2696,40 @@ 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 + 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 + 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 + Added Position + Added Margin + Reduced Margin + Realized PnL + Unrealized PnL 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/api/request/perps/OpenOrderRequestTest.kt b/app/src/test/java/one/mixin/android/api/request/perps/OpenOrderRequestTest.kt index 5767f03593..52244a9d6f 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) + } + @Test fun serializesLeaderPositionIdForIncreaseOrder() { val request = IncreaseOrderRequest( 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..9eb85b2200 --- /dev/null +++ b/app/src/test/java/one/mixin/android/db/perps/PerpsOrderDaoTest.kt @@ -0,0 +1,97 @@ +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.Assert.assertNull +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 }) + } + + @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", + 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/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/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/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" 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("")) 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)) + } + } +} 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..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,26 +1,169 @@ 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 +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.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 nonServerErrorStopsAfterOneRequest() = runBlocking { + 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 + val price = requestLiquidationPrice(onFailure = { errorMessage = it }) { + liquidationPriceResult( + price = null, + errorCode = 10653, + availableMargin = BigDecimal("50"), + errorMessage = "Margin exceeds the limit", + ) + } + + assertNull(price) + assertEquals("Margin exceeds the limit", errorMessage) + } + + @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 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 @@ -46,12 +189,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/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/PerpsMarginAmountTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt new file mode 100644 index 0000000000..de7654497e --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarginAmountTest.kt @@ -0,0 +1,182 @@ +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.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", 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)) { + assertEquals("0", maximumMarginReductionInput("100", BigDecimal.ZERO, isPercentage)) + val input = maximumMarginReductionInput("6.98", BigDecimal("2.07999999"), isPercentage) + val reduction = requireNotNull(reduceMarginAmount("6.98", input, isPercentage)) + assertTrue(reduction <= BigDecimal("2.07999999")) + } + } + + @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.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)) + } + + @Test + fun marginPreviewKeepsCurrencyForZeroAndTheActualLiquidationPrice() { + 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)) + assertEquals("100", formatMarginAdjustmentInput(BigDecimal("100"), true)) + assertEquals("0", formatMarginAdjustmentInput(BigDecimal.ZERO, false)) + assertEquals("0", formatMarginAdjustmentInput(BigDecimal("0.009"), 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)) + } + + @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")) + 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.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 { + 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))) + } + } +} 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)) + } + } +} 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( 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)) + } +} 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( 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/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")) } } 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/util/analytics/AnalyticsRulesTest.kt b/app/src/test/java/one/mixin/android/util/analytics/AnalyticsRulesTest.kt index 0304780678..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 @@ -5,6 +5,19 @@ import kotlin.test.assertNull import org.junit.Test class AnalyticsRulesTest { + @Test + fun perpsAdjustmentsPreserveLinkSourcesAndUseTheActionEntryForOtherSources() { + 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 fun marketShareEventUsesMarketShareNameAndParams() { val event = 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