From ba35bb3fc5d1442d70e33b65e2691d6307d39371 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Feb 2026 17:54:15 +0800 Subject: [PATCH 001/105] Trading perpetual --- app/src/main/AndroidManifest.xml | 5 + .../api/request/perps/OpenOrderRequest.kt | 43 ++ .../android/api/response/perps/CandleView.kt | 35 ++ .../android/api/response/perps/MarketView.kt | 42 ++ .../api/response/perps/PositionView.kt | 53 ++ .../mixin/android/api/service/RouteService.kt | 48 ++ .../android/ui/home/web3/trade/CandleChart.kt | 165 ++++++ .../ui/home/web3/trade/HelpBottomSheet.kt | 79 +++ .../home/web3/trade/MarketDetailActivity.kt | 40 ++ .../ui/home/web3/trade/MarketDetailPage.kt | 203 +++++++ .../home/web3/trade/MarketListBottomSheet.kt | 159 ++++++ .../ui/home/web3/trade/PerpetualContent.kt | 420 ++++++++++++++ .../home/web3/trade/PerpetualGuideFragment.kt | 38 ++ .../ui/home/web3/trade/PerpetualGuidePage.kt | 532 ++++++++++++++++++ .../ui/home/web3/trade/PerpetualViewModel.kt | 103 ++++ .../ui/home/web3/trade/TradeFragment.kt | 4 + .../android/ui/home/web3/trade/TradePage.kt | 57 +- app/src/main/res/values-zh-rCN/strings.xml | 48 ++ app/src/main/res/values/strings.xml | 48 ++ 19 files changed, 2118 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/api/request/perps/OpenOrderRequest.kt create mode 100644 app/src/main/java/one/mixin/android/api/response/perps/CandleView.kt create mode 100644 app/src/main/java/one/mixin/android/api/response/perps/MarketView.kt create mode 100644 app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailActivity.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuideFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuidePage.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 421d4dba48..ef7c54a3f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -166,6 +166,11 @@ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:theme="@style/AppTheme.NoActionBar" android:windowSoftInputMode="adjustResize|stateAlwaysHidden" /> + +) + +data class CandleItem( + @SerializedName("timestamp") + val timestamp: Long, + @SerializedName("open") + val open: String, + @SerializedName("high") + val high: String, + @SerializedName("low") + val low: String, + @SerializedName("close") + val close: String, + @SerializedName("volume") + val volume: String, + @SerializedName("amount") + val amount: String, + @SerializedName("count") + val count: Long +) diff --git a/app/src/main/java/one/mixin/android/api/response/perps/MarketView.kt b/app/src/main/java/one/mixin/android/api/response/perps/MarketView.kt new file mode 100644 index 0000000000..e215ac54f8 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/MarketView.kt @@ -0,0 +1,42 @@ +package one.mixin.android.api.response.perps + +import com.google.gson.annotations.SerializedName + +data class MarketView( + @SerializedName("market_id") + val marketId: String, + @SerializedName("market") + val market: String, + @SerializedName("symbol") + val symbol: String, + @SerializedName("mark_price") + val markPrice: String, + @SerializedName("funding_rate") + val fundingRate: String, + @SerializedName("maker_fee") + val makerFee: String, + @SerializedName("taker_fee") + val takerFee: String, + @SerializedName("min_order_size") + val minOrderSize: String, + @SerializedName("max_order_size") + val maxOrderSize: String, + @SerializedName("min_order_value") + val minOrderValue: String, + @SerializedName("quantity_increment") + val quantityIncrement: String, + @SerializedName("price_increment") + val priceIncrement: String, + @SerializedName("last") + val last: String, + @SerializedName("volume") + val volume: String, + @SerializedName("leverage") + val leverage: Int, + @SerializedName("icon_url") + val iconUrl: String, + @SerializedName("change") + val change: String, + @SerializedName("updated_at") + val updatedAt: String +) diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt new file mode 100644 index 0000000000..d451d80e2d --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt @@ -0,0 +1,53 @@ +package one.mixin.android.api.response.perps + +import com.google.gson.annotations.SerializedName + +data class PositionView( + @SerializedName("position_id") + val positionId: String, + @SerializedName("market_symbol") + val marketSymbol: String, + @SerializedName("side") + val side: String, + @SerializedName("quantity") + val quantity: String, + @SerializedName("entry_price") + val entryPrice: String, + @SerializedName("margin") + val margin: String, + @SerializedName("leverage") + val leverage: Int, + @SerializedName("state") + val state: String, + @SerializedName("mark_price") + val markPrice: String, + @SerializedName("unrealized_pnl") + val unrealizedPnl: String, + @SerializedName("roe") + val roe: String +) + +data class PositionHistoryView( + @SerializedName("history_id") + val historyId: String, + @SerializedName("position_id") + val positionId: String, + @SerializedName("market_symbol") + val marketSymbol: String, + @SerializedName("side") + val side: String, + @SerializedName("quantity") + val quantity: String, + @SerializedName("entry_price") + val entryPrice: String, + @SerializedName("close_price") + val closePrice: String, + @SerializedName("realized_pnl") + val realizedPnl: String, + @SerializedName("leverage") + val leverage: Int, + @SerializedName("open_at") + val openAt: String, + @SerializedName("closed_at") + val closedAt: String +) 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 72cd1dcb07..00d3df9e13 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 @@ -57,6 +57,14 @@ import retrofit2.http.POST import retrofit2.http.Path import one.mixin.android.api.request.LimitOrderRequest import one.mixin.android.api.response.CreateLimitOrderResponse +import one.mixin.android.api.request.perps.OpenOrderRequest +import one.mixin.android.api.request.perps.OpenOrderResponse +import one.mixin.android.api.request.perps.CloseOrderRequest +import one.mixin.android.api.request.perps.CloseOrderResponse +import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.api.response.perps.CandleView +import one.mixin.android.api.response.perps.PositionView +import one.mixin.android.api.response.perps.PositionHistoryView import retrofit2.http.Query @@ -344,4 +352,44 @@ interface RouteService { @Path("user_id") userId: String, @Query("chain_id") chainId: String ): MixinResponse + + // Perps API + @GET("perps/markets") + suspend fun getPerpsMarkets( + @Query("offset") offset: Int = 0, + @Query("limit") limit: Int = 20 + ): MixinResponse> + + @GET("perps/market") + suspend fun getPerpsMarket( + @Query("market_id") marketId: String + ): MixinResponse + + @GET("perps/markets/candles") + suspend fun getPerpsCandles( + @Query("product") product: String, + @Query("time_frame") timeFrame: String + ): MixinResponse + + @POST("perps/orders/open") + suspend fun openPerpsOrder( + @Body request: OpenOrderRequest + ): MixinResponse + + @POST("perps/orders/close") + suspend fun closePerpsOrder( + @Body request: CloseOrderRequest + ): MixinResponse + + @GET("perps/positions") + suspend fun getPerpsPositions( + @Query("wallet_id") walletId: String + ): MixinResponse> + + @GET("perps/positions/history") + suspend fun getPerpsPositionHistory( + @Query("offset") offset: String? = null, + @Query("limit") limit: Int = 100, + @Query("wallet_id") walletId: String + ): MixinResponse> } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt new file mode 100644 index 0000000000..fa29ba04ee --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -0,0 +1,165 @@ +package one.mixin.android.ui.home.web3.trade + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import one.mixin.android.api.response.perps.CandleView +import one.mixin.android.compose.theme.MixinAppTheme +import java.math.BigDecimal +import kotlin.math.max +import kotlin.math.min + +@Composable +fun CandleChart( + marketId: String, + timeFrame: String +) { + val viewModel = hiltViewModel() + var candles by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(marketId, timeFrame) { + isLoading = true + errorMessage = null + viewModel.loadCandles( + marketId = marketId, + timeFrame = timeFrame, + onSuccess = { data -> + candles = data + isLoading = false + }, + onError = { error -> + errorMessage = error + isLoading = false + } + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = MixinAppTheme.colors.accent + ) + } + errorMessage != null -> { + Text( + text = errorMessage ?: "Error loading chart", + fontSize = 14.sp, + color = MixinAppTheme.colors.red + ) + } + candles.isEmpty() -> { + Text( + text = "No data available", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + else -> { + PerpsCandleChartCanvas(candles = candles) + } + } + } +} + +@Composable +private fun PerpsCandleChartCanvas(candles: List) { + val greenColor = Color(0xFF4CAF50) + val redColor = Color(0xFFF44336) + + Canvas(modifier = Modifier.fillMaxSize()) { + val items = candles.firstOrNull()?.items ?: emptyList() + if (items.isEmpty()) return@Canvas + + val width = size.width + val height = size.height + val padding = 40f + val chartWidth = width - padding * 2 + val chartHeight = height - padding * 2 + + val candleCount = items.size + if (candleCount == 0) return@Canvas + + val candleWidth = (chartWidth / candleCount) * 0.6f + val spacing = (chartWidth / candleCount) * 0.4f + + val prices = mutableListOf() + items.forEach { item -> + item.high.toBigDecimalOrNull()?.let { prices.add(it) } + item.low.toBigDecimalOrNull()?.let { prices.add(it) } + } + + if (prices.isEmpty()) return@Canvas + + val maxPrice = prices.maxOrNull() ?: BigDecimal.ZERO + val minPrice = prices.minOrNull() ?: BigDecimal.ZERO + val priceRange = maxPrice - minPrice + + if (priceRange == BigDecimal.ZERO) return@Canvas + + items.forEachIndexed { index, item -> + val open = item.open.toBigDecimalOrNull() ?: return@forEachIndexed + val close = item.close.toBigDecimalOrNull() ?: return@forEachIndexed + val high = item.high.toBigDecimalOrNull() ?: return@forEachIndexed + val low = item.low.toBigDecimalOrNull() ?: return@forEachIndexed + + val isGreen = close >= open + val color = if (isGreen) greenColor else redColor + + val x = padding + index * (candleWidth + spacing) + candleWidth / 2 + + val highY = padding + chartHeight - ((high - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val lowY = padding + chartHeight - ((low - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val openY = padding + chartHeight - ((open - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val closeY = padding + chartHeight - ((close - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + + drawLine( + color = color, + start = Offset(x, highY), + end = Offset(x, lowY), + strokeWidth = 2f + ) + + val top = min(openY, closeY) + val bottom = max(openY, closeY) + val bodyHeight = max(bottom - top, 2f) + + drawRect( + color = color, + topLeft = Offset(x - candleWidth / 2, top), + size = Size(candleWidth, bodyHeight) + ) + } + } +} + +private fun String.toBigDecimalOrNull(): BigDecimal? { + return try { + BigDecimal(this) + } catch (e: Exception) { + null + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt new file mode 100644 index 0000000000..f36e03782e --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt @@ -0,0 +1,79 @@ +package one.mixin.android.ui.home.web3.trade + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme + +@Composable +fun HelpBottomSheetContent( + onContactSupport: () -> Unit, + onTradingGuide: () -> Unit, + onDismiss: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MixinAppTheme.colors.background) + .padding(vertical = 16.dp) + ) { + HelpOption( + title = stringResource(R.string.Contact_Support), + onClick = onContactSupport + ) + + Spacer(modifier = Modifier.height(1.dp)) + + HelpOption( + title = stringResource(R.string.Trading_Guide), + onClick = onTradingGuide + ) + + Spacer(modifier = Modifier.height(8.dp)) + + HelpOption( + title = stringResource(R.string.Cancel), + onClick = onDismiss + ) + } +} + +@Composable +private fun HelpOption( + title: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.Medium + ) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailActivity.kt new file mode 100644 index 0000000000..1d760e5743 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailActivity.kt @@ -0,0 +1,40 @@ +package one.mixin.android.ui.home.web3.trade + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.ui.common.BaseActivity + +@AndroidEntryPoint +class MarketDetailActivity : BaseActivity() { + + companion object { + private const val EXTRA_MARKET_ID = "extra_market_id" + private const val EXTRA_MARKET_SYMBOL = "extra_market_symbol" + + fun show(context: Context, marketId: String, marketSymbol: String) { + val intent = Intent(context, MarketDetailActivity::class.java).apply { + putExtra(EXTRA_MARKET_ID, marketId) + putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) + } + context.startActivity(intent) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val marketId = intent.getStringExtra(EXTRA_MARKET_ID) ?: "" + val marketSymbol = intent.getStringExtra(EXTRA_MARKET_SYMBOL) ?: "" + + setContent { + MarketDetailPage( + marketId = marketId, + marketSymbol = marketSymbol, + onBack = { finish() } + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt new file mode 100644 index 0000000000..ed02094a54 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -0,0 +1,203 @@ +package one.mixin.android.ui.home.web3.trade + +import PageScaffold +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.rememberCoroutineScope +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import kotlinx.coroutines.launch +import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.ui.home.web3.components.OutlinedTab +import one.mixin.android.ui.wallet.alert.components.cardBackground +import java.math.BigDecimal + +@Composable +fun MarketDetailPage( + marketId: String, + marketSymbol: String, + onBack: () -> Unit +) { + val viewModel = hiltViewModel() + var market by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var selectedTimeFrame by remember { mutableIntStateOf(0) } + val coroutineScope = rememberCoroutineScope() + + val timeFrames = listOf("1h", "1d", "1w", "1M") + + LaunchedEffect(marketId) { + viewModel.loadMarketDetail( + marketId = marketId, + onSuccess = { data -> + market = data + isLoading = false + }, + onError = { + isLoading = false + } + ) + } + + PageScaffold( + title = marketSymbol, + verticalScrollable = false, + pop = onBack + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + // 价格信息卡片 + if (market != null) { + PriceInfoCard(market = market!!) + } else if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Loading...", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + timeFrames.forEachIndexed { index, timeFrame -> + OutlinedTab( + text = timeFrame, + selected = selectedTimeFrame == index, + showBadge = false, + onClick = { coroutineScope.launch { selectedTimeFrame = index } } + ) + if (index < timeFrames.size - 1) Spacer(modifier = Modifier.width(8.dp)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + if (market != null) { + CandleChart( + marketId = marketId, + timeFrame = timeFrames[selectedTimeFrame] + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Loading chart...", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun PriceInfoCard(market: MarketView) { + val change = try { + BigDecimal(market.change) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isPositive = change >= BigDecimal.ZERO + val changeColor = if (isPositive) Color(0xFF4CAF50) else Color(0xFFF44336) + val changeText = "${if (isPositive) "+" else ""}${market.change}%" + + val formattedPrice = try { + val price = BigDecimal(market.markPrice) + if (price >= BigDecimal("1000")) { + String.format("%.2f", price) + } else if (price >= BigDecimal("1")) { + String.format("%.4f", price) + } else { + String.format("%.6f", price) + } + } catch (e: Exception) { + market.markPrice + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = "Mark Price", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.Bottom + ) { + Text( + text = "$$formattedPrice", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = changeText, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = changeColor, + modifier = Modifier.padding(start = 8.dp, bottom = 4.dp) + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt new file mode 100644 index 0000000000..26bdc35553 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt @@ -0,0 +1,159 @@ +package one.mixin.android.ui.home.web3.trade + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.R +import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.numberFormatCompact +import java.math.BigDecimal + +@Composable +fun MarketListBottomSheetContent( + markets: List, + onMarketClick: (MarketView) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .padding(16.dp) + ) { + Text( + text = "Select Market", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.padding(bottom = 16.dp) + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth() + ) { + items(markets) { market -> + MarketListItem( + market = market, + onClick = { onMarketClick(market) } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } +} + +@Composable +private fun MarketListItem( + market: MarketView, + onClick: () -> Unit +) { + val change = try { + BigDecimal(market.change) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isPositive = change >= BigDecimal.ZERO + val changeColor = if (isPositive) Color(0xFF4CAF50) else Color(0xFFF44336) + val changeText = "${if (isPositive) "+" else ""}${market.change}%" + + val formattedPrice = try { + val price = BigDecimal(market.markPrice) + if (price >= BigDecimal("1000")) { + String.format("%.2f", price) + } else if (price >= BigDecimal("1")) { + String.format("%.4f", price) + } else { + String.format("%.6f", price) + } + } catch (e: Exception) { + market.markPrice + } + + val formattedVolume = try { + BigDecimal(market.volume).numberFormatCompact() + } catch (e: Exception) { + market.volume + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + CoilImage( + model = market.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = market.symbol, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Vol $formattedVolume", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = "$$formattedPrice", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = changeText, + fontSize = 12.sp, + color = changeColor, + fontWeight = FontWeight.Medium + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt new file mode 100644 index 0000000000..31d0ca469e --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -0,0 +1,420 @@ +package one.mixin.android.ui.home.web3.trade + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import kotlinx.coroutines.launch +import one.mixin.android.compose.CoilImage +import one.mixin.android.Constants +import one.mixin.android.R +import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.numberFormatCompact +import one.mixin.android.extension.openUrl +import one.mixin.android.ui.wallet.alert.components.cardBackground +import java.math.BigDecimal + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun PerpetualContent( + onLongClick: (MarketView) -> Unit, + onShortClick: (MarketView) -> Unit, + onShowTradingGuide: () -> Unit +) { + val context = LocalContext.current + val viewModel = hiltViewModel() + val coroutineScope = rememberCoroutineScope() + + var markets by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + var isLongMode by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + viewModel.loadMarkets( + onSuccess = { data -> + markets = data + isLoading = false + }, + onError = { error -> + errorMessage = error + isLoading = false + } + ) + } + + ModalBottomSheetLayout( + sheetState = bottomSheetState, + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + sheetBackgroundColor = MixinAppTheme.colors.background, + sheetContent = { + MarketListBottomSheetContent( + markets = markets, + onMarketClick = { market -> + coroutineScope.launch { + bottomSheetState.hide() + MarketDetailActivity.show(context, market.marketId, market.symbol) + } + } + ) + } + ) { + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp), + ) { + Text( + text = "Total Position Value", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "$0.00", + fontSize = 18.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "$0.00", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "(0.0%)", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Open Positions(0)", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.ic_empty_transaction), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "No Positions", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onShowTradingGuide() + } + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "How perps works?", + fontSize = 14.sp, + color = MixinAppTheme.colors.accent, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp)) { + + // Markets Section + Text( + text = "Markets", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Loading...", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } else if (errorMessage != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = errorMessage ?: "Error loading markets", + fontSize = 14.sp, + color = MixinAppTheme.colors.red, + textAlign = TextAlign.Center + ) + } + } else { + markets.take(2).forEach { market -> + MarketItem( + market = market, + onClick = { + MarketDetailActivity.show(context, market.marketId, market.symbol) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + + // Long and Short Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + isLongMode = true + coroutineScope.launch { + bottomSheetState.show() + } + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color(0xFF4CAF50), + contentColor = Color.White + ), + enabled = markets.isNotEmpty() + ) { + Text( + text = "Long", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + + Button( + onClick = { + isLongMode = false + coroutineScope.launch { + bottomSheetState.show() + } + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color(0xFFF44336), + contentColor = Color.White + ), + enabled = markets.isNotEmpty() + ) { + Text( + text = "Short", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +fun MarketItem(market: MarketView, onClick: () -> Unit = {}) { + val change = try { + BigDecimal(market.change) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isPositive = change >= BigDecimal.ZERO + val changeColor = if (isPositive) Color(0xFF4CAF50) else Color(0xFFF44336) + val changeText = "${if (isPositive) "+" else ""}${market.change}%" + + val formattedPrice = try { + val price = BigDecimal(market.markPrice) + if (price >= BigDecimal("1000")) { + String.format("%.2f", price) + } else if (price >= BigDecimal("1")) { + String.format("%.4f", price) + } else { + String.format("%.6f", price) + } + } catch (e: Exception) { + market.markPrice + } + + // 使用 numberFormatCompact 格式化成交量 + val formattedVolume = try { + BigDecimal(market.volume).numberFormatCompact() + } catch (e: Exception) { + market.volume + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + CoilImage( + model = market.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = market.symbol, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Vol $formattedVolume", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = "$$formattedPrice", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = changeText, + fontSize = 12.sp, + color = changeColor, + fontWeight = FontWeight.Medium + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuideFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuideFragment.kt new file mode 100644 index 0000000000..928f37a28e --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuideFragment.kt @@ -0,0 +1,38 @@ +package one.mixin.android.ui.home.web3.trade + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.compose.theme.MixinAppTheme +@AndroidEntryPoint +class PerpetualGuideFragment : Fragment() { + + companion object { + const val TAG = "PerpetualGuideFragment" + + fun newInstance() = PerpetualGuideFragment() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + MixinAppTheme { + PerpetualGuidePage( + pop = { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + ) + } + } + } + } +} + diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuidePage.kt new file mode 100644 index 0000000000..326782137e --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuidePage.kt @@ -0,0 +1,532 @@ +package one.mixin.android.ui.home.web3.trade + +import PageScaffold +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.ui.home.web3.components.OutlinedTab +import one.mixin.android.ui.wallet.alert.components.cardBackground + +data class ScenarioData( + val scenario: String, + val change: String, + val changeValue: String, + val pnl: String, + val isProfit: Boolean, +) + +@Composable +fun PerpetualGuidePage(pop: () -> Unit) { + var selectedTab by remember { mutableIntStateOf(0) } + val coroutineScope = rememberCoroutineScope() + + val tabs = listOf( + stringResource(R.string.Perpetual_Guide_Overview), + stringResource(R.string.Perpetual_Guide_Long), + stringResource(R.string.Perpetual_Guide_Short), + stringResource(R.string.Perpetual_Guide_Leverage), + stringResource(R.string.Perpetual_Guide_Position) + ) + + PageScaffold( + title = stringResource(R.string.Trading_Guide), + verticalScrollable = false, + pop = pop + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + tabs.forEachIndexed { index, tab -> + OutlinedTab( + text = tab, + selected = selectedTab == index, + showBadge = false, + onClick = { coroutineScope.launch { selectedTab = index } } + ) + if (index < tabs.size - 1) Spacer(modifier = Modifier.width(10.dp)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + when (selectedTab) { + 0 -> OverviewContent() + 1 -> LongContent() + 2 -> ShortContent() + 3 -> LeverageContent() + 4 -> PositionContent() + } + Spacer(modifier = Modifier.height(24.dp)) + } + } + } +} + + +@Composable +private fun OverviewContent() { + GuideSection( + title = stringResource(R.string.Perpetual_Guide_Overview_Title), + content = stringResource(R.string.Perpetual_Guide_Overview_Desc) + ) +} + +@Composable +private fun LongContent() { + ExampleWithScenariosCard( + title = stringResource(R.string.Perpetual_Example), + rows = listOf( + stringResource(R.string.Perpetual_Trading_Pair) to "BTC - USD", + stringResource(R.string.Perpetual_Direction) to "Long", + stringResource(R.string.Perpetual_Leverage_Times) to "10x", + stringResource(R.string.Perpetual_Investment) to "1,000 USDT" + ), + scenarios = listOf( + ScenarioData( + stringResource(R.string.Perpetual_Scenario_1), + stringResource(R.string.Perpetual_Price_Up), + "10%", + "+100 USDT (+10%)", + true + ), + ScenarioData( + stringResource(R.string.Perpetual_Scenario_2), + stringResource(R.string.Perpetual_Price_Down), + "10%", + "-100 USDT (-10%)", + false + ) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + DescriptionWithRulesCard( + description = stringResource(R.string.Perpetual_Long_Desc), + rules = listOf( + stringResource(R.string.Perpetual_Price_Up) to stringResource(R.string.Perpetual_Profit), + stringResource(R.string.Perpetual_Price_Down) to stringResource(R.string.Perpetual_Loss) + ) + ) +} + +@Composable +private fun ShortContent() { + ExampleWithScenariosCard( + title = stringResource(R.string.Perpetual_Example), + rows = listOf( + stringResource(R.string.Perpetual_Trading_Pair) to "ETH - USD", + stringResource(R.string.Perpetual_Direction) to "Short", + stringResource(R.string.Perpetual_Leverage_Times) to "10x", + stringResource(R.string.Perpetual_Investment) to "1,000 USDT" + ), + scenarios = listOf( + ScenarioData( + stringResource(R.string.Perpetual_Scenario_1), + stringResource(R.string.Perpetual_Price_Down), + "10%", + "+100 USDT (+10%)", + true + ), + ScenarioData( + stringResource(R.string.Perpetual_Scenario_2), + stringResource(R.string.Perpetual_Price_Up), + "10%", + "-100 USDT (-10%)", + false + ) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + DescriptionWithRulesCard( + description = stringResource(R.string.Perpetual_Short_Desc), + rules = listOf( + stringResource(R.string.Perpetual_Price_Down) to stringResource(R.string.Perpetual_Profit), + stringResource(R.string.Perpetual_Price_Up) to stringResource(R.string.Perpetual_Loss) + ) + ) +} + +@Composable +private fun LeverageContent() { + ExampleWithScenariosCard( + title = stringResource(R.string.Perpetual_Example), + rows = listOf( + stringResource(R.string.Perpetual_Trading_Pair) to "SOL - USD", + stringResource(R.string.Perpetual_Direction) to "Long", + stringResource(R.string.Perpetual_Leverage_Times) to "10x", + stringResource(R.string.Perpetual_Investment) to "1,000 USDT" + ), + scenarios = listOf( + ScenarioData( + stringResource(R.string.Perpetual_Scenario_1), + stringResource(R.string.Perpetual_Price_Up), + "10%", + "+1,000 USDT (+100%)", + true + ), + ScenarioData( + stringResource(R.string.Perpetual_Scenario_2), + stringResource(R.string.Perpetual_Price_Down), + "10%", + "-1,000 USDT (-100%)", + false + ) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + DescriptionWithInfoAndRiskCard( + description = stringResource(R.string.Perpetual_Leverage_Desc), + infoTitle = stringResource(R.string.Perpetual_PnL_Impact), + infoContent = stringResource(R.string.Perpetual_Leverage_Impact), + riskContent = stringResource(R.string.Perpetual_Leverage_Risk) + ) +} + +@Composable +private fun PositionContent() { + ExampleWithScenariosCard( + title = stringResource(R.string.Perpetual_Example), + rows = listOf( + stringResource(R.string.Perpetual_Trading_Pair) to "SOL - USD", + stringResource(R.string.Perpetual_Direction) to "Long", + stringResource(R.string.Perpetual_Leverage_Times) to "10x", + stringResource(R.string.Perpetual_Investment) to "1,000 USDT", + stringResource(R.string.Perpetual_Position_Value) to "10,000 USDT (74.62 SOL)" + ), + scenarios = listOf( + ScenarioData( + stringResource(R.string.Perpetual_Scenario_1), + stringResource(R.string.Perpetual_Price_Up), + "10%", + "+1,000 USDT (+100%)", + true + ), + ScenarioData( + stringResource(R.string.Perpetual_Scenario_2), + stringResource(R.string.Perpetual_Price_Down), + "10%", + "-1,000 USDT (-100%)", + false + ) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + DescriptionWithInfoAndRiskCard( + description = stringResource(R.string.Perpetual_Position_Desc), + infoTitle = stringResource(R.string.Perpetual_Position_Usage), + infoContent = stringResource(R.string.Perpetual_Position_Usage_Desc), + riskContent = stringResource(R.string.Perpetual_Position_Risk) + ) +} + + +@Composable +private fun GuideSection(title: String, content: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = content, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textAssist + ) + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + listOf( + stringResource(R.string.Perpetual_Feature_1), + stringResource(R.string.Perpetual_Feature_2), + stringResource(R.string.Perpetual_Feature_3), + stringResource(R.string.Perpetual_Feature_4), + stringResource(R.string.Perpetual_Feature_5) + ) + .forEach { feature -> + Row(modifier = Modifier.padding(vertical = 4.dp)) { + Text(text = "• ", fontSize = 14.sp, color = MixinAppTheme.colors.textAssist) + Text( + text = feature, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.weight(1f) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.Perpetual_Risk_Warning), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textMinor + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.Perpetual_Risk_Warning_Content), + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textMinor + ) + } +} + +@Composable +private fun ExampleWithScenariosCard( + title: String, + rows: List>, + scenarios: List, +) { + Column( + modifier = Modifier + .fillMaxWidth() + + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(12.dp)) + rows.forEachIndexed { index, (label, value) -> + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.weight(1f) + ) + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + scenarios.forEachIndexed { index, scenario -> + if (index > 0) { + Spacer(modifier = Modifier.height(12.dp)) + } + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = scenario.scenario, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = scenario.change, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.weight(1f) + ) + Text( + text = scenario.changeValue, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (scenario.isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.Perpetual_PnL), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.weight(1f) + ) + Text( + text = scenario.pnl, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (scenario.isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) + ) + } + } + } + } +} + +@Composable +private fun DescriptionWithRulesCard( + description: String, + rules: List>, +) { + Column( + modifier = Modifier + .fillMaxWidth() + + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.Perpetual_Detail_Desc), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textAssist + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.Perpetual_PnL_Rules), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(12.dp)) + rules.forEach { (condition, result) -> + Row(modifier = Modifier.padding(vertical = 4.dp)) { + Text(text = "$condition:", fontSize = 14.sp, color = MixinAppTheme.colors.textAssist) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = result, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + } + } + } +} + +@Composable +private fun DescriptionWithInfoAndRiskCard( + description: String, + infoTitle: String, + infoContent: String, + riskContent: String, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.Perpetual_Detail_Desc), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textAssist + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = infoTitle, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textMinor + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = infoContent, + fontSize = 14.sp, + lineHeight = 18.sp, + color = MixinAppTheme.colors.textMinor + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.Perpetual_Risk_Warning), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textMinor + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = riskContent, + fontSize = 14.sp, + lineHeight = 18.sp, + color = MixinAppTheme.colors.textMinor + ) + } + } +} + diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt new file mode 100644 index 0000000000..fc5c7b12fa --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -0,0 +1,103 @@ +package one.mixin.android.ui.home.web3.trade + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import one.mixin.android.api.response.perps.CandleView +import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.api.service.RouteService +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class PerpetualViewModel @Inject constructor( + private val routeService: RouteService +) : ViewModel() { + + fun loadMarkets( + onSuccess: (List) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + routeService.getPerpsMarkets(offset = 0, limit = 100) + } + + val data = response.data + if (response.isSuccess && data != null) { + Timber.d("Perps markets loaded: ${data.size} markets") + onSuccess(data) + } else { + val error = "Failed to load markets: ${response.errorDescription}" + Timber.e(error) + onError(error) + } + } catch (e: Exception) { + val error = "Error loading markets: ${e.message}" + Timber.e(e, error) + onError(error) + } + } + } + + fun loadMarketDetail( + marketId: String, + onSuccess: (MarketView) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + routeService.getPerpsMarket(marketId) + } + + val data = response.data + if (response.isSuccess && data != null) { + Timber.d("Market detail loaded: ${data.symbol}") + onSuccess(data) + } else { + val error = "Failed to load market detail: ${response.errorDescription}" + Timber.e(error) + onError(error) + } + } catch (e: Exception) { + val error = "Error loading market detail: ${e.message}" + Timber.e(e, error) + onError(error) + } + } + } + + fun loadCandles( + marketId: String, + timeFrame: String, + onSuccess: (List) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + routeService.getPerpsCandles(marketId, timeFrame) + } + + val data = response.data + if (response.isSuccess && data != null) { + Timber.d("Candles loaded: ${data.items.size} items") + onSuccess(listOf(data)) + } else { + val error = "Failed to load candles: ${response.errorDescription}" + Timber.e(error) + onError(error) + } + } catch (e: Exception) { + val error = "Error loading candles: ${e.message}" + Timber.e(e, error) + onError(error) + } + } + } +} 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 11dfd27db7..33210a0f3f 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 @@ -302,6 +302,10 @@ class TradeFragment : BaseFragment() { this@apply.hideKeyboard() navTo(OrderDetailFragment.newInstance(orderId), OrderDetailFragment.TAG) }, + onShowTradingGuide = { + this@apply.hideKeyboard() + navTo(PerpetualGuideFragment.newInstance(), PerpetualGuideFragment.TAG) + }, pop = { navigateUp(navController) } 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 b998854b73..3164994cec 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 @@ -22,7 +22,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -85,12 +88,19 @@ fun TradePage( onSwitchToLimitOrder: (String, SwapToken, SwapToken) -> Unit, pop: () -> Unit, onLimitOrderClick: (String) -> Unit, + onShowTradingGuide: () -> Unit, ) { val context = LocalContext.current val viewModel = hiltViewModel() var walletDisplayName by remember { mutableStateOf(null) } var pendingOrderCount by remember { mutableIntStateOf(0) } + + val bottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + skipHalfExpanded = true + ) + val coroutineScope = rememberCoroutineScope() val currentWalletId = walletId ?: Session.getAccountId() ?: "" val pendingCount by viewModel.getPendingOrderCountByWallet(currentWalletId).collectAsStateWithLifecycle(initialValue = 0) @@ -112,9 +122,7 @@ fun TradePage( } } - val coroutineScope = rememberCoroutineScope() - - val tabCount = 2 + val tabCount = 3 val pagerState = rememberPagerState( initialPage = initialTabIndex.coerceIn(0, tabCount - 1), pageCount = { tabCount }, @@ -156,9 +164,47 @@ fun TradePage( onLimitOrderClick, onOrderList, ) + }, + TabItem(title = stringResource(R.string.Perpetual)) { + PerpetualContent( + onLongClick = { market -> + // TODO: Handle long position + }, + onShortClick = { market -> + // TODO: Handle short position + }, + onShowTradingGuide = onShowTradingGuide + ) } ) + ModalBottomSheetLayout( + sheetState = bottomSheetState, + scrimColor = Color.Black.copy(alpha = 0.6f), + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + sheetBackgroundColor = MixinAppTheme.colors.background, + sheetContent = { + HelpBottomSheetContent( + onContactSupport = { + coroutineScope.launch { + bottomSheetState.hide() + context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + } + }, + onTradingGuide = { + coroutineScope.launch { + bottomSheetState.hide() + onShowTradingGuide() + } + }, + onDismiss = { + coroutineScope.launch { + bottomSheetState.hide() + } + } + ) + } + ) { PageScaffold( title = stringResource(id = R.string.Trade), subtitle = { @@ -230,7 +276,9 @@ fun TradePage( } } IconButton(onClick = { - context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + coroutineScope.launch { + bottomSheetState.show() + } }) { Icon( painter = painterResource(id = R.drawable.ic_support), @@ -279,6 +327,7 @@ fun TradePage( } } } +} /** * @return True if the input was successful, false if the balance is insufficient, or null if the input is invalid. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 65c86a5e7a..22b6d19e34 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2175,4 +2175,52 @@ 未找到可用报价。请尝试其他代币或金额。 Safe 金库、观察钱包和隐藏的资产不计入统计 共管的 Safe 金库不计入统计 + 交易说明 + 简介 + 做多 + 做空 + 杠杆 + 平仓 + 永续合约允许您使用杠杆交易加密货币,从而放大您的潜在利润(和损失)。您可以做多(押注价格上涨)或做空(押注价格下跌),而无需拥有标的资产。 + 做多意味着您预期价格会上涨。如果价格上涨,您就会获利。如果价格下跌,您就会亏损。您的盈亏会被杠杆倍数放大。 + 做空意味着您预期价格会下跌。如果价格下跌,您就会获利。如果价格上涨,您就会亏损。您的盈亏会被杠杆倍数放大。 + 杠杆允许您用更少的资金控制更大的仓位。例如,使用 10 倍杠杆,1%% 的价格变动会导致 10%% 的盈亏。杠杆越高,风险越大。 + 您可以随时平仓以实现盈亏。平仓价格基于当前市场价格。请务必监控您的仓位以避免爆仓。 + 仓位 + 具体说明 + Mixin 永续合约是一种以数字资产结算的衍生品交易方式,支持做多和做空,无到期日。通过杠杆机制,交易者可以放大仓位,把握价格上涨或下跌带来的交易机会。 + 产品特点 + 无到期日,可长期持仓 + 支持做多 / 做空,双向交易 + 最高支持 100 倍杠杆 + 支持逐仓模式,灵活控制风险 + 延迟爆仓,防插针 + 风险提示 + 杠杆交易可能放大收益,同时也会放大亏损。当保证金不足时,仓位可能被强制平仓。请合理控制杠杆倍数与仓位规模,谨慎交易。 + 举例说明 + 交易对 + 开仓方向 + 杠杆倍数 + 投入资金 + 仓位价值 + 场景一:价格上涨 + 场景二:价格下跌 + 价格上涨 + 价格下跌 + 盈亏 + 具体说明 + 做多是指在预期价格上涨时建立仓位,价格上涨可获得盈利,价格下跌则产生亏损。 + 做空是指在预期价格下跌时建立仓位,价格下跌可获得盈利,价格上涨则产生亏损。 + 产生盈利 + 产生亏损 + 盈亏规则 + 杠杆倍数用于放大交易规模,以较少的保证金控制更大的合约仓位。 + 盈亏影响 + 杠杆会同时放大收益和亏损。杠杆倍数越高,盈亏随价格波动的变化越大。 + 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 + 当前合约持仓的实际交易价值,由「保证金 × 杠杆」决定。 + 用途 + 支撑当前仓位,抵扣浮动亏损。 + 当投入资金不足以支撑当前仓位时,仓位将被系统强制平仓。价格剧烈波动可能会快速消耗投入资金。 + 永续合约 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c324b71e70..f3e3d847f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2231,4 +2231,52 @@ No available quotes found. Please try a different token or amount. Safes, watch wallets, and hidden assets are excluded from the total Co-managed safes are excluded from the total + Trading Guide + Overview + Long + Short + Leverage + Close Position + Perpetual contracts allow you to trade cryptocurrency with leverage, enabling you to amplify your potential profits (and losses). You can go long (bet on price increase) or short (bet on price decrease) without owning the underlying asset. + Going long means you expect the price to increase. If the price goes up, you profit. If it goes down, you lose. Your profit/loss is multiplied by your leverage. + Going short means you expect the price to decrease. If the price goes down, you profit. If it goes up, you lose. Your profit/loss is multiplied by your leverage. + Leverage allows you to control a larger position with less capital. For example, with 10x leverage, a 1%% price move results in a 10%% profit or loss. Higher leverage means higher risk. + You can close your position at any time to realize your profit or loss. The closing price is based on the current market price. Make sure to monitor your positions to avoid liquidation. + Position + Instructions + Mixin perpetual contracts are derivative trading instruments settled in digital assets, supporting long and short positions with no expiration date. Through leverage, traders can amplify positions to capture trading opportunities from price movements. + Product Features + No expiration date, can hold positions long-term + Support long/short, bidirectional trading + Up to 100x leverage supported + Support isolated margin mode for flexible risk control + Delayed liquidation to prevent flash crashes + Risk Warning + Leverage trading can amplify gains but also losses. When margin is insufficient, positions may be forcibly liquidated. Please control leverage and position size reasonably and trade cautiously. + Example + Trading Pair + Direction + Leverage + Investment + Position Value + Scenario 1: Price Rise + Scenario 2: Price Fall + Price Rise + Price Fall + P&L + Detailed Description + Going long means establishing a position when expecting price to rise. Profit when price rises, loss when price falls. + Going short means establishing a position when expecting price to fall. Profit when price falls, loss when price rises. + Profit + Loss + P&L Rules + Leverage multiplier is used to amplify trading size, controlling larger contract positions with less margin. + P&L Impact + Leverage amplifies both gains and losses. Higher leverage means greater P&L fluctuation with price movements. + Please choose leverage reasonably. With high leverage, even small price movements can lead to significant losses. + The actual trading value of current contract position, determined by "Margin × Leverage". + Usage + Support current position and offset floating losses. + When investment is insufficient to support current position, the position will be forcibly liquidated by the system. Severe price volatility may rapidly consume investment. + Perpetual From 43be0e5c6d2dfd3ceb5eae1d60d95acc79592e53 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 10 Feb 2026 18:14:49 +0800 Subject: [PATCH 002/105] Update candle --- .../android/ui/home/web3/trade/CandleChart.kt | 363 ++++++++++++++++-- .../ui/home/web3/trade/MarketDetailPage.kt | 326 ++++++++++++---- .../ui/home/web3/trade/PerpetualViewModel.kt | 4 +- app/src/main/res/drawable/ic_perps_help.xml | 12 + 4 files changed, 591 insertions(+), 114 deletions(-) create mode 100644 app/src/main/res/drawable/ic_perps_help.xml diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index fa29ba04ee..c7c172a76d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -1,9 +1,23 @@ package one.mixin.android.ui.home.web3.trade import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -14,33 +28,46 @@ import androidx.compose.runtime.remember 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.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import one.mixin.android.Constants import one.mixin.android.api.response.perps.CandleView import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences import java.math.BigDecimal import kotlin.math.max import kotlin.math.min @Composable fun CandleChart( - marketId: String, + symbol: String, timeFrame: String ) { + val context = LocalContext.current val viewModel = hiltViewModel() var candles by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } - LaunchedEffect(marketId, timeFrame) { + LaunchedEffect(symbol, timeFrame) { isLoading = true errorMessage = null viewModel.loadCandles( - marketId = marketId, + symbol = symbol, timeFrame = timeFrame, onSuccess = { data -> candles = data @@ -54,7 +81,8 @@ fun CandleChart( } Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), contentAlignment = Alignment.Center ) { when { @@ -79,45 +107,192 @@ fun CandleChart( ) } else -> { - PerpsCandleChartCanvas(candles = candles) + ScrollableCandleChart(candles = candles, context = context) } } } } @Composable -private fun PerpsCandleChartCanvas(candles: List) { - val greenColor = Color(0xFF4CAF50) - val redColor = Color(0xFFF44336) +private fun ScrollableCandleChart(candles: List, context: android.content.Context) { + val items = candles.firstOrNull()?.items ?: emptyList() + if (items.isEmpty()) return - Canvas(modifier = Modifier.fillMaxSize()) { - val items = candles.firstOrNull()?.items ?: emptyList() - if (items.isEmpty()) return@Canvas + val candleWidth = 6.dp + val spacing = 2.dp + val density = LocalDensity.current + + val scrollState = rememberScrollState() + var visibleRange by remember { mutableStateOf(Pair(0, items.size)) } + var touchPosition by remember { mutableStateOf(null) } + var isTouching by remember { mutableStateOf(false) } + + LaunchedEffect(items.size) { + if (items.size > 50) { + scrollState.scrollTo(scrollState.maxValue) + } + } + + LaunchedEffect(scrollState.value, scrollState.maxValue) { + if (items.isNotEmpty()) { + val candleWidthPx = with(density) { (candleWidth + spacing).toPx() } + val containerWidth = with(density) { 200.dp.toPx() } + + val startIndex = (scrollState.value / candleWidthPx).toInt().coerceIn(0, items.size - 1) + val visibleCount = (containerWidth / candleWidthPx).toInt() + 2 + val endIndex = (startIndex + visibleCount).coerceIn(0, items.size) + + visibleRange = Pair(startIndex, endIndex) + } + } - val width = size.width - val height = size.height - val padding = 40f - val chartWidth = width - padding * 2 - val chartHeight = height - padding * 2 + val visibleItems = items.subList( + visibleRange.first.coerceIn(0, items.size), + visibleRange.second.coerceIn(0, items.size) + ) - val candleCount = items.size - if (candleCount == 0) return@Canvas + val prices = mutableListOf() + visibleItems.forEach { item -> + item.high.toBigDecimalOrNull()?.let { prices.add(it) } + item.low.toBigDecimalOrNull()?.let { prices.add(it) } + } - val candleWidth = (chartWidth / candleCount) * 0.6f - val spacing = (chartWidth / candleCount) * 0.4f + val maxPrice = prices.maxOrNull() ?: BigDecimal.ZERO + val minPrice = prices.minOrNull() ?: BigDecimal.ZERO + val avgPrice = (maxPrice + minPrice) / BigDecimal(2) + + val lastItem = items.lastOrNull() + val currentPrice = if (visibleRange.second >= items.size) { + lastItem?.close?.toBigDecimalOrNull() + } else { + null + } - val prices = mutableListOf() - items.forEach { item -> - item.high.toBigDecimalOrNull()?.let { prices.add(it) } - item.low.toBigDecimalOrNull()?.let { prices.add(it) } + Row(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + isTouching = true + touchPosition = offset + tryAwaitRelease() + isTouching = false + touchPosition = null + } + ) + } + .horizontalScroll(scrollState) + ) { + PerpsCandleChartCanvas( + candles = candles, + context = context, + candleWidth = candleWidth, + spacing = spacing, + visibleRange = visibleRange, + touchPosition = if (isTouching) touchPosition else null, + scrollOffset = scrollState.value, + maxPrice = maxPrice, + minPrice = minPrice + ) } - if (prices.isEmpty()) return@Canvas + BoxWithConstraints( + modifier = Modifier + .fillMaxHeight() + .wrapContentSize() + .padding(start = 4.dp, top = 8.dp, bottom = 8.dp, end = 4.dp) + ) { + val containerHeight = maxHeight + + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatPrice(maxPrice), + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = formatPrice(avgPrice), + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = formatPrice(minPrice), + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary + ) + } - val maxPrice = prices.maxOrNull() ?: BigDecimal.ZERO - val minPrice = prices.minOrNull() ?: BigDecimal.ZERO - val priceRange = maxPrice - minPrice + if (currentPrice != null) { + val priceRange = maxPrice - minPrice + if (priceRange > BigDecimal.ZERO) { + val priceRatio = ((currentPrice - minPrice).toFloat() / priceRange.toFloat()).coerceIn(0f, 1f) + val offsetY = containerHeight * (1f - priceRatio) + + Box( + modifier = Modifier + .align(Alignment.TopStart) + .offset(y = offsetY) + ) { + Text( + text = formatPrice(currentPrice), + fontSize = 10.sp, + color = Color.White, + modifier = Modifier + .background( + color = Color(0xFF2196F3), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + } + } + } + } +} + +@Composable +private fun PerpsCandleChartCanvas( + candles: List, + context: android.content.Context, + candleWidth: androidx.compose.ui.unit.Dp, + spacing: androidx.compose.ui.unit.Dp, + visibleRange: Pair, + touchPosition: Offset?, + scrollOffset: Int, + maxPrice: BigDecimal, + minPrice: BigDecimal +) { + val quoteColorPref = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val textMeasurer = rememberTextMeasurer() + val upColor = if (quoteColorPref) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val downColor = if (quoteColorPref) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + + Canvas( + modifier = Modifier + .fillMaxSize() + .width(((candleWidth + spacing) * (candles.firstOrNull()?.items?.size ?: 0))) + ) { + val items = candles.firstOrNull()?.items ?: emptyList() + if (items.isEmpty()) return@Canvas + + val height = size.height + val paddingTop = 8f + val paddingBottom = 8f + val paddingLeft = 8f + val chartHeight = height - paddingTop - paddingBottom + val candleWidthPx = candleWidth.toPx() + val spacingPx = spacing.toPx() + + val priceRange = maxPrice - minPrice if (priceRange == BigDecimal.ZERO) return@Canvas items.forEachIndexed { index, item -> @@ -126,15 +301,15 @@ private fun PerpsCandleChartCanvas(candles: List) { val high = item.high.toBigDecimalOrNull() ?: return@forEachIndexed val low = item.low.toBigDecimalOrNull() ?: return@forEachIndexed - val isGreen = close >= open - val color = if (isGreen) greenColor else redColor + val isUp = close >= open + val color = if (isUp) upColor else downColor - val x = padding + index * (candleWidth + spacing) + candleWidth / 2 + val x = paddingLeft + index * (candleWidthPx + spacingPx) + candleWidthPx / 2 - val highY = padding + chartHeight - ((high - minPrice).toFloat() / priceRange.toFloat() * chartHeight) - val lowY = padding + chartHeight - ((low - minPrice).toFloat() / priceRange.toFloat() * chartHeight) - val openY = padding + chartHeight - ((open - minPrice).toFloat() / priceRange.toFloat() * chartHeight) - val closeY = padding + chartHeight - ((close - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val highY = paddingTop + chartHeight - ((high - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val lowY = paddingTop + chartHeight - ((low - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val openY = paddingTop + chartHeight - ((open - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val closeY = paddingTop + chartHeight - ((close - minPrice).toFloat() / priceRange.toFloat() * chartHeight) drawLine( color = color, @@ -147,12 +322,124 @@ private fun PerpsCandleChartCanvas(candles: List) { val bottom = max(openY, closeY) val bodyHeight = max(bottom - top, 2f) - drawRect( + drawRoundRect( color = color, - topLeft = Offset(x - candleWidth / 2, top), - size = Size(candleWidth, bodyHeight) + topLeft = Offset(x - candleWidthPx / 2, top), + size = Size(candleWidthPx, bodyHeight), + cornerRadius = CornerRadius(1f, 1f) ) } + + val lastItem = items.lastOrNull() + if (lastItem != null && visibleRange.second >= items.size) { + val lastClose = lastItem.close.toBigDecimalOrNull() + if (lastClose != null) { + drawCurrentPriceLine( + price = lastClose, + minPrice = minPrice, + priceRange = priceRange, + paddingTop = paddingTop, + chartHeight = chartHeight, + paddingLeft = paddingLeft + ) + } + } + + touchPosition?.let { touch -> + val adjustedX = touch.x + scrollOffset + val candleIndex = ((adjustedX - paddingLeft) / (candleWidthPx + spacingPx)).toInt() + if (candleIndex in items.indices) { + val item = items[candleIndex] + val close = item.close.toBigDecimalOrNull() + if (close != null) { + val priceY = paddingTop + chartHeight - ((close - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + drawTouchCrosshair( + x = paddingLeft + candleIndex * (candleWidthPx + spacingPx) + candleWidthPx / 2, + y = priceY, + price = close, + width = size.width, + paddingTop = paddingTop, + paddingBottom = paddingBottom, + paddingLeft = paddingLeft, + textMeasurer = textMeasurer + ) + } + } + } + } +} + +private fun DrawScope.drawCurrentPriceLine( + price: BigDecimal, + minPrice: BigDecimal, + priceRange: BigDecimal, + paddingTop: Float, + chartHeight: Float, + paddingLeft: Float +) { + val y = paddingTop + chartHeight - ((price - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + + drawLine( + color = Color(0xFF2196F3), + start = Offset(paddingLeft, y), + end = Offset(size.width, y), + strokeWidth = 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) + ) +} + +private fun DrawScope.drawTouchCrosshair( + x: Float, + y: Float, + price: BigDecimal, + width: Float, + paddingTop: Float, + paddingBottom: Float, + paddingLeft: Float, + textMeasurer: androidx.compose.ui.text.TextMeasurer +) { + val lineColor = Color(0xFF9E9E9E) + + drawLine( + color = lineColor, + start = Offset(paddingLeft, y), + end = Offset(width, y), + strokeWidth = 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) + ) + + drawLine( + color = lineColor, + start = Offset(x, paddingTop), + end = Offset(x, size.height - paddingBottom), + strokeWidth = 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) + ) + + val priceText = formatPrice(price) + val textLayoutResult = textMeasurer.measure( + text = priceText, + style = TextStyle(fontSize = 10.sp, color = Color.White) + ) + + drawRoundRect( + color = Color(0xFF2196F3), + topLeft = Offset(width - textLayoutResult.size.width - 8f, y - textLayoutResult.size.height / 2 - 2f), + size = Size(textLayoutResult.size.width + 8f, textLayoutResult.size.height + 4f), + cornerRadius = CornerRadius(4f, 4f) + ) + + drawText( + textLayoutResult = textLayoutResult, + topLeft = Offset(width - textLayoutResult.size.width - 4f, y - textLayoutResult.size.height / 2) + ) +} + +private fun formatPrice(price: BigDecimal): String { + return when { + price >= BigDecimal("100") -> String.format("%.0f", price) + price >= BigDecimal("1") -> String.format("%.2f", price) + else -> String.format("%.6f", price) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt index ed02094a54..33d2981095 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -1,6 +1,11 @@ package one.mixin.android.ui.home.web3.trade import PageScaffold +import android.graphics.drawable.Icon +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,10 +14,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -26,14 +34,20 @@ 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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch +import one.mixin.android.Constants +import one.mixin.android.R import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme -import one.mixin.android.ui.home.web3.components.OutlinedTab +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal @@ -41,7 +55,7 @@ import java.math.BigDecimal fun MarketDetailPage( marketId: String, marketSymbol: String, - onBack: () -> Unit + onBack: () -> Unit, ) { val viewModel = hiltViewModel() var market by remember { mutableStateOf(null) } @@ -77,74 +91,172 @@ fun MarketDetailPage( ) { Spacer(modifier = Modifier.height(16.dp)) - // 价格信息卡片 - if (market != null) { - PriceInfoCard(market = market!!) - } else if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "Loading...", - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row(modifier = Modifier.fillMaxWidth()) { - timeFrames.forEachIndexed { index, timeFrame -> - OutlinedTab( - text = timeFrame, - selected = selectedTimeFrame == index, - showBadge = false, - onClick = { coroutineScope.launch { selectedTimeFrame = index } } - ) - if (index < timeFrames.size - 1) Spacer(modifier = Modifier.width(8.dp)) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - Column( modifier = Modifier .fillMaxWidth() - .height(300.dp) .clip(RoundedCornerShape(8.dp)) .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) .padding(16.dp) ) { if (market != null) { - CandleChart( - marketId = marketId, - timeFrame = timeFrames[selectedTimeFrame] + MarketDetailCard( + market = market!!, + marketSymbol = marketSymbol, + selectedTimeFrame = selectedTimeFrame, + timeFrames = timeFrames, + onTimeFrameChange = { index -> + coroutineScope.launch { selectedTimeFrame = index } + } ) - } else { + } else if (isLoading) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxWidth() + .height(400.dp), contentAlignment = Alignment.Center ) { - Text( - text = "Loading chart...", - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = MixinAppTheme.colors.accent ) } } } + Spacer(modifier = Modifier.height(16.dp)) + + if (market != null) { + MarketInfoCard( + market = market!!, + onLearnClick = { /* TODO: Navigate to guide */ } + ) + } + Spacer(modifier = Modifier.height(24.dp)) } } } @Composable -private fun PriceInfoCard(market: MarketView) { +private fun MarketInfoCard( + market: MarketView, + onLearnClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onLearnClick() }, + verticalAlignment = Alignment.CenterVertically + ) { + Image(painter = painterResource(id = R.drawable.ic_perps_help), contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "How perps works?", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = "Learn how to trade perps", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = "24H VOLUME", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "$${formatVolume(market.volume)}", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + + Column { + Text( + text = "Open Interest", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "-", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Column { + Text( + text = "Funding Rate", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${market.fundingRate}%", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + } + } +} + +private fun formatVolume(volume: String): String { + return try { + val vol = BigDecimal(volume) + when { + vol >= BigDecimal("1000000000") -> String.format("%.2fB", vol.divide(BigDecimal("1000000000"))) + vol >= BigDecimal("1000000") -> String.format("%.2fM", vol.divide(BigDecimal("1000000"))) + vol >= BigDecimal("1000") -> String.format("%.2fK", vol.divide(BigDecimal("1000"))) + else -> String.format("%.2f", vol) + } + } catch (e: Exception) { + volume + } +} + +@Composable +private fun MarketDetailCard( + market: MarketView, + marketSymbol: String, + selectedTimeFrame: Int, + timeFrames: List, + onTimeFrameChange: (Int) -> Unit, +) { + val context = LocalContext.current + val quoteColorPref = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val change = try { BigDecimal(market.change) } catch (e: Exception) { @@ -152,7 +264,19 @@ private fun PriceInfoCard(market: MarketView) { } val isPositive = change >= BigDecimal.ZERO - val changeColor = if (isPositive) Color(0xFF4CAF50) else Color(0xFFF44336) + val changeColor = if (isPositive) { + if (quoteColorPref) { + MixinAppTheme.colors.marketRed + } else { + MixinAppTheme.colors.marketGreen + } + } else { + if (quoteColorPref) { + MixinAppTheme.colors.marketGreen + } else { + MixinAppTheme.colors.marketRed + } + } val changeText = "${if (isPositive) "+" else ""}${market.change}%" val formattedPrice = try { @@ -168,36 +292,90 @@ private fun PriceInfoCard(market: MarketView) { market.markPrice } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) - .padding(16.dp) - ) { - Text( - text = "Mark Price", - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(8.dp)) + Column(modifier = Modifier.fillMaxWidth()) { Row( - verticalAlignment = Alignment.Bottom + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "$$formattedPrice", - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = MixinAppTheme.colors.textPrimary + Column(modifier = Modifier.weight(1f)) { + Text( + text = marketSymbol, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "$$formattedPrice", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = changeText, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = changeColor + ) + } + + CoilImage( + model = market.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = changeText, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = changeColor, - modifier = Modifier.padding(start = 8.dp, bottom = 4.dp) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Box(modifier = Modifier + .fillMaxWidth() + .height(200.dp)) { + CandleChart( + symbol = marketSymbol, + timeFrame = timeFrames[selectedTimeFrame] ) } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + timeFrames.forEachIndexed { index, timeFrame -> + Box( + modifier = Modifier + .weight(1f) + .height(36.dp) + .clip(RoundedCornerShape(18.dp)) + .then( + if (selectedTimeFrame == index) { + Modifier.background(MixinAppTheme.colors.backgroundWindow) + } else { + Modifier + } + ) + .clickable { onTimeFrameChange(index) }, + contentAlignment = Alignment.Center + ) { + Text( + text = timeFrame, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (selectedTimeFrame == index) { + MixinAppTheme.colors.textPrimary + } else { + MixinAppTheme.colors.textAssist + } + ) + } + } + } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index fc5c7b12fa..e03446dd33 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -73,7 +73,7 @@ class PerpetualViewModel @Inject constructor( } fun loadCandles( - marketId: String, + symbol: String, timeFrame: String, onSuccess: (List) -> Unit, onError: (String) -> Unit @@ -81,7 +81,7 @@ class PerpetualViewModel @Inject constructor( viewModelScope.launch { try { val response = withContext(Dispatchers.IO) { - routeService.getPerpsCandles(marketId, timeFrame) + routeService.getPerpsCandles(symbol, timeFrame) } val data = response.data diff --git a/app/src/main/res/drawable/ic_perps_help.xml b/app/src/main/res/drawable/ic_perps_help.xml new file mode 100644 index 0000000000..953e2361ff --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_help.xml @@ -0,0 +1,12 @@ + + + + From c79cdd3af73ecb89796819a6cb837b4e2d210fc6 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 10 Feb 2026 23:11:50 +0800 Subject: [PATCH 003/105] Update open position --- app/src/main/AndroidManifest.xml | 2 +- .../home/web3/trade/MarketDetailActivity.kt | 40 -- .../ui/home/web3/trade/MarketDetailPage.kt | 82 ++- .../ui/home/web3/trade/OpenPositionPage.kt | 636 ++++++++++++++++++ .../ui/home/web3/trade/PerpetualContent.kt | 4 +- .../ui/home/web3/trade/PerpetualViewModel.kt | 22 +- .../ui/home/web3/trade/PerpsActivity.kt | 74 ++ .../android/ui/home/web3/trade/TradePage.kt | 108 +-- app/src/main/res/values-zh-rCN/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + 10 files changed, 893 insertions(+), 93 deletions(-) delete mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailActivity.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ef7c54a3f8..7dfe247694 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -167,7 +167,7 @@ android:theme="@style/AppTheme.NoActionBar" android:windowSoftInputMode="adjustResize|stateAlwaysHidden" /> diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailActivity.kt deleted file mode 100644 index 1d760e5743..0000000000 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailActivity.kt +++ /dev/null @@ -1,40 +0,0 @@ -package one.mixin.android.ui.home.web3.trade - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import dagger.hilt.android.AndroidEntryPoint -import one.mixin.android.ui.common.BaseActivity - -@AndroidEntryPoint -class MarketDetailActivity : BaseActivity() { - - companion object { - private const val EXTRA_MARKET_ID = "extra_market_id" - private const val EXTRA_MARKET_SYMBOL = "extra_market_symbol" - - fun show(context: Context, marketId: String, marketSymbol: String) { - val intent = Intent(context, MarketDetailActivity::class.java).apply { - putExtra(EXTRA_MARKET_ID, marketId) - putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) - } - context.startActivity(intent) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val marketId = intent.getStringExtra(EXTRA_MARKET_ID) ?: "" - val marketSymbol = intent.getStringExtra(EXTRA_MARKET_SYMBOL) ?: "" - - setContent { - MarketDetailPage( - marketId = marketId, - marketSymbol = marketSymbol, - onBack = { finish() } - ) - } - } -} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt index 33d2981095..be7341f248 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -57,6 +59,7 @@ fun MarketDetailPage( marketSymbol: String, onBack: () -> Unit, ) { + val context = LocalContext.current val viewModel = hiltViewModel() var market by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } @@ -132,6 +135,77 @@ fun MarketDetailPage( ) } + Spacer(modifier = Modifier.height(16.dp)) + + if (market != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + modifier = Modifier + .weight(1f) + .height(48.dp), + onClick = { + PerpsActivity.showOpenPosition( + context = context, + marketId = marketId, + marketSymbol = marketSymbol, + isLong = true + ) + }, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MixinAppTheme.colors.walletGreen + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp + ) + ) { + Text( + text = "Long", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + + Button( + modifier = Modifier + .weight(1f) + .height(48.dp), + onClick = { + PerpsActivity.showOpenPosition( + context = context, + marketId = marketId, + marketSymbol = marketSymbol, + isLong = false + ) + }, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MixinAppTheme.colors.walletRed + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp + ) + ) { + Text( + text = "Short", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) } } @@ -266,15 +340,15 @@ private fun MarketDetailCard( val isPositive = change >= BigDecimal.ZERO val changeColor = if (isPositive) { if (quoteColorPref) { - MixinAppTheme.colors.marketRed + MixinAppTheme.colors.walletRed } else { - MixinAppTheme.colors.marketGreen + MixinAppTheme.colors.walletGreen } } else { if (quoteColorPref) { - MixinAppTheme.colors.marketGreen + MixinAppTheme.colors.walletGreen } else { - MixinAppTheme.colors.marketRed + MixinAppTheme.colors.walletRed } } val changeText = "${if (isPositive) "+" else ""}${market.change}%" diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt new file mode 100644 index 0000000000..ed1b1e51ac --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -0,0 +1,636 @@ +package one.mixin.android.ui.home.web3.trade + +import PageScaffold +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Slider +import androidx.compose.material.SliderDefaults +import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import kotlinx.coroutines.launch +import one.mixin.android.R +import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.safe.TokenItem +import java.math.BigDecimal +import kotlin.math.abs + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun OpenPositionPage( + marketId: String, + marketSymbol: String, + isLong: Boolean, + onBack: () -> Unit, +) { + val context = LocalContext.current + val viewModel = hiltViewModel() + val coroutineScope = rememberCoroutineScope() + val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + val tokenBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) + + var market by remember { mutableStateOf(null) } + var selectedToken by remember { mutableStateOf(null) } + var availableTokens by remember { mutableStateOf>(emptyList()) } + var usdtAmount by remember { mutableStateOf("") } + var leverage by remember { mutableFloatStateOf(10f) } + + LaunchedEffect(marketId) { + viewModel.loadMarketDetail( + marketId = marketId, + onSuccess = { data -> + market = data + if (data.leverage > 0) { + leverage = minOf(10f, data.leverage.toFloat()) + } + }, + onError = {} + ) + + viewModel.loadUsdTokens { tokens -> + availableTokens = tokens + selectedToken = tokens.firstOrNull() + } + } + + val maxLeverage = market?.leverage ?: 100 + val leverageOptions = generateLeverageOptions(maxLeverage) + + ModalBottomSheetLayout( + sheetState = if (tokenBottomSheetState.isVisible) tokenBottomSheetState else bottomSheetState, + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + sheetBackgroundColor = MixinAppTheme.colors.background, + sheetContent = { + if (tokenBottomSheetState.isVisible) { + TokenSelectionBottomSheet( + tokens = availableTokens, + selectedToken = selectedToken, + onTokenSelect = { token -> + selectedToken = token + coroutineScope.launch { tokenBottomSheetState.hide() } + } + ) + } else { + LeverageBottomSheet( + currentLeverage = leverage, + maxLeverage = maxLeverage, + onLeverageChange = { + leverage = it + coroutineScope.launch { bottomSheetState.hide() } + } + ) + } + } + ) { + PageScaffold( + title = "Open Position", + verticalScrollable = false, + pop = onBack + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + market?.let { m -> + CoilImage( + model = m.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "${if (isLong) "Long" else "Short"} $marketSymbol", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = "$${m.markPrice}", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textAssist + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = "Amount", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + androidx.compose.material.TextField( + value = usdtAmount, + onValueChange = { usdtAmount = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("0.00") }, + colors = androidx.compose.material.TextFieldDefaults.textFieldColors( + backgroundColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + ) + + Row( + modifier = Modifier.clickable { + coroutineScope.launch { tokenBottomSheetState.show() } + }, + verticalAlignment = Alignment.CenterVertically + ) { + selectedToken?.let { token -> + CoilImage( + model = token.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = selectedToken?.symbol ?: "USDT", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_down_info), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Balance: ${selectedToken?.balance ?: "0"} ${selectedToken?.symbol ?: ""}", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + + Text( + text = "MAX", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.accent, + modifier = Modifier.clickable { + usdtAmount = selectedToken?.balance ?: "0" + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + + Text( + text = "Leverage", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + modifier = Modifier.clickable { + coroutineScope.launch { bottomSheetState.show() } + }, + text = "${leverage.toInt()}x", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + leverageOptions.forEach { lev -> + Box( + modifier = Modifier + .weight(1f) + .height(32.dp) + .clip(RoundedCornerShape(4.dp)) + .border( + width = 1.dp, + color = MixinAppTheme.colors.textAssist, + shape = RoundedCornerShape(4.dp) + ) + .clickable { leverage = lev.toFloat() }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${lev}x", + fontSize = 12.sp, + color = MixinAppTheme.colors.textPrimary + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + val profitInfo = calculateProfitInfo( + amount = usdtAmount, + leverage = leverage, + isLong = isLong, + priceChangePercent = 1.0 + ) + + Text( + text = profitInfo, + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.padding(horizontal = 4.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "Order Value", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = calculateOrderValue(usdtAmount, leverage), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = "Liquidation Price", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = calculateLiquidationPrice( + market?.markPrice ?: "0", + leverage, + isLong + ), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(16.dp)) + + androidx.compose.material.Button( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .height(48.dp), + onClick = { + // TODO: Open position + }, + enabled = usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO, + colors = androidx.compose.material.ButtonDefaults.outlinedButtonColors( + backgroundColor = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { + if (isLong) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + } else { + MixinAppTheme.colors.backgroundGrayLight + } + ), + shape = RoundedCornerShape(32.dp), + elevation = androidx.compose.material.ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp + ) + ) { + Text( + text = "Review", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { + Color.White + } else { + MixinAppTheme.colors.textAssist + } + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } + } +} + +@Composable +private fun TokenSelectionBottomSheet( + tokens: List, + selectedToken: TokenItem?, + onTokenSelect: (TokenItem) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Select Token", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(16.dp)) + + tokens.forEach { token -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onTokenSelect(token) } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CoilImage( + model = token.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = token.symbol, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = token.chainName ?: "", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + } + + Text( + text = token.balance, + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + + if (selectedToken?.assetId == token.assetId) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = MixinAppTheme.colors.accent, + modifier = Modifier.size(20.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun LeverageBottomSheet( + currentLeverage: Float, + maxLeverage: Int, + onLeverageChange: (Float) -> Unit, +) { + var tempLeverage by remember { mutableFloatStateOf(currentLeverage) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Select Leverage", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "${tempLeverage.toInt()}x", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Slider( + value = tempLeverage, + onValueChange = { tempLeverage = it }, + valueRange = 1f..maxLeverage.toFloat(), + steps = maxLeverage - 2, + colors = SliderDefaults.colors( + thumbColor = MixinAppTheme.colors.accent, + activeTrackColor = MixinAppTheme.colors.accent, + inactiveTrackColor = MixinAppTheme.colors.backgroundWindow + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "1x", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Text( + text = "${maxLeverage}x", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MixinAppTheme.colors.accent) + .clickable { onLeverageChange(tempLeverage) }, + contentAlignment = Alignment.Center + ) { + Text( + text = "Confirm", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +private fun generateLeverageOptions(maxLeverage: Int): List { + val options = mutableListOf() + val baseOptions = listOf(1, 2, 5, 10, 25, 50, 100) + + baseOptions.forEach { option -> + if (option <= maxLeverage) { + options.add(option) + } + } + + return options.take(7) +} + +private fun calculateProfitInfo( + amount: String, + leverage: Float, + isLong: Boolean, + priceChangePercent: Double, +): String { + val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (amountValue == BigDecimal.ZERO) return "Price up 1% → Profit 0% (+$0.00)" + + val profitPercent = priceChangePercent * leverage + val profitAmount = amountValue * BigDecimal(profitPercent / 100) + + val direction = if (isLong) "up" else "down" + val sign = if (profitAmount >= BigDecimal.ZERO) "+" else "" + + return "Price $direction ${String.format("%.0f", abs(priceChangePercent))}% → Profit ${sign}${String.format("%.1f", profitPercent)}% (${sign}$${String.format("%.2f", profitAmount)})" +} + +private fun calculateOrderValue(amount: String, leverage: Float): String { + val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO + val orderValue = amountValue * BigDecimal(leverage.toDouble()) + return "$${String.format("%.2f", orderValue)}" +} + +private fun calculateLiquidationPrice( + currentPrice: String, + leverage: Float, + isLong: Boolean, +): String { + val price = currentPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (price == BigDecimal.ZERO) return "$0" + + val liquidationPercent = BigDecimal(100.0 / leverage) + val liquidationPrice = if (isLong) { + price * (BigDecimal.ONE - liquidationPercent / BigDecimal(100)) + } else { + price * (BigDecimal.ONE + liquidationPercent / BigDecimal(100)) + } + + return "$${String.format("%.2f", liquidationPrice)}" +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index 31d0ca469e..ef917b2200 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -96,7 +96,7 @@ fun PerpetualContent( onMarketClick = { market -> coroutineScope.launch { bottomSheetState.hide() - MarketDetailActivity.show(context, market.marketId, market.symbol) + PerpsActivity.showDetail(context, market.marketId, market.symbol) } } ) @@ -259,7 +259,7 @@ fun PerpetualContent( MarketItem( market = market, onClick = { - MarketDetailActivity.show(context, market.marketId, market.symbol) + PerpsActivity.showDetail(context, market.marketId, market.symbol) } ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index e03446dd33..344e94877f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -9,12 +9,14 @@ import kotlinx.coroutines.withContext import one.mixin.android.api.response.perps.CandleView import one.mixin.android.api.response.perps.MarketView import one.mixin.android.api.service.RouteService +import one.mixin.android.vo.safe.TokenItem import timber.log.Timber import javax.inject.Inject @HiltViewModel class PerpetualViewModel @Inject constructor( - private val routeService: RouteService + private val routeService: RouteService, + private val tokenDao: one.mixin.android.db.TokenDao ) : ViewModel() { fun loadMarkets( @@ -100,4 +102,22 @@ class PerpetualViewModel @Inject constructor( } } } + + fun loadUsdTokens(onSuccess: (List) -> Unit) { + viewModelScope.launch { + try { + val usdTokens = withContext(Dispatchers.IO) { + val usdIds = one.mixin.android.Constants.usdIds + tokenDao.findTokenItems(usdIds) + .sortedByDescending { + it.balance.toBigDecimalOrNull() ?: java.math.BigDecimal.ZERO + } + } + onSuccess(usdTokens) + } catch (e: Exception) { + Timber.e(e, "Error loading USD tokens") + onSuccess(emptyList()) + } + } + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt new file mode 100644 index 0000000000..7a73a44331 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt @@ -0,0 +1,74 @@ +package one.mixin.android.ui.home.web3.trade + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.ui.common.BaseActivity + +@AndroidEntryPoint +class PerpsActivity : BaseActivity() { + + companion object { + private const val EXTRA_MARKET_ID = "extra_market_id" + private const val EXTRA_MARKET_SYMBOL = "extra_market_symbol" + private const val EXTRA_MODE = "extra_mode" + private const val EXTRA_IS_LONG = "extra_is_long" + + const val MODE_DETAIL = "detail" + const val MODE_OPEN_POSITION = "open_position" + + fun showDetail(context: Context, marketId: String, marketSymbol: String) { + val intent = Intent(context, PerpsActivity::class.java).apply { + putExtra(EXTRA_MARKET_ID, marketId) + putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) + putExtra(EXTRA_MODE, MODE_DETAIL) + } + context.startActivity(intent) + } + + fun showOpenPosition(context: Context, marketId: String, marketSymbol: String, isLong: Boolean) { + val intent = Intent(context, PerpsActivity::class.java).apply { + putExtra(EXTRA_MARKET_ID, marketId) + putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) + putExtra(EXTRA_MODE, MODE_OPEN_POSITION) + putExtra(EXTRA_IS_LONG, isLong) + } + context.startActivity(intent) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val marketId = intent.getStringExtra(EXTRA_MARKET_ID) ?: "" + val marketSymbol = intent.getStringExtra(EXTRA_MARKET_SYMBOL) ?: "" + val mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_DETAIL + val isLong = intent.getBooleanExtra(EXTRA_IS_LONG, true) + + setContent { + MixinAppTheme { + when (mode) { + MODE_OPEN_POSITION -> { + OpenPositionPage( + marketId = marketId, + marketSymbol = marketSymbol, + isLong = isLong, + onBack = { finish() } + ) + } + + else -> { + MarketDetailPage( + marketId = marketId, + marketSymbol = marketSymbol, + onBack = { finish() } + ) + } + } + } + } + } +} 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 3164994cec..d1481bd55a 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 @@ -122,62 +122,80 @@ fun TradePage( } } - val tabCount = 3 - val pagerState = rememberPagerState( - initialPage = initialTabIndex.coerceIn(0, tabCount - 1), - pageCount = { tabCount }, - ) + // Build tabs dynamically: Perpetual tab should only exist when walletId == null + val switchToLimitRequested = remember { mutableStateOf(false) } - val tabs = listOf( - TabItem(stringResource(id = R.string.Trade_Simple)) { - SwapContent( - from = swapFrom, - to = swapTo, - inMixin = inMixin, - initialAmount = initialAmount, - lastOrderTime = lastOrderTime, - reviewing = reviewing, - source = source, - onSelectToken = { isReverse, type -> onSelectToken(isReverse, type, false) }, - onReview = onReview, - onDeposit = onDeposit, - onSwitchToLimitOrder = { inputText, fromToken, toToken -> - onSwitchToLimitOrder(inputText, fromToken, toToken) - coroutineScope.launch { - pagerState.animateScrollToPage(1) - } - onDismissLimitOrderTabBadge() - onTabChanged(1) - }, - ) - }, - TabItem(stringResource(id = R.string.Trade_Advanced)) { - LimitOrderContent( - limitFrom, - limitTo, - inMixin, - initialAmount, - lastOrderTime, - { isReverse, type -> onSelectToken(isReverse, type, true) }, - onLimitReview, - onDeposit, - onLimitOrderClick, - onOrderList, - ) - }, - TabItem(title = stringResource(R.string.Perpetual)) { + val tabs = mutableListOf() + + tabs += TabItem(stringResource(id = R.string.Trade_Simple)) { + SwapContent( + from = swapFrom, + to = swapTo, + inMixin = inMixin, + initialAmount = initialAmount, + lastOrderTime = lastOrderTime, + reviewing = reviewing, + source = source, + onSelectToken = { isReverse, type -> onSelectToken(isReverse, type, false) }, + onReview = onReview, + onDeposit = onDeposit, + onSwitchToLimitOrder = { inputText, fromToken, toToken -> + // Notify parent and request navigation to Limit tab locally + onSwitchToLimitOrder(inputText, fromToken, toToken) + switchToLimitRequested.value = true + onDismissLimitOrderTabBadge() + onTabChanged(1) + }, + ) + } + + tabs += TabItem(stringResource(id = R.string.Trade_Advanced)) { + LimitOrderContent( + limitFrom, + limitTo, + inMixin, + initialAmount, + lastOrderTime, + { isReverse, type -> onSelectToken(isReverse, type, true) }, + onLimitReview, + onDeposit, + onLimitOrderClick, + onOrderList, + ) + } + + if (walletId == null) { + tabs += TabItem(title = stringResource(R.string.Perpetual)) { PerpetualContent( - onLongClick = { market -> + onLongClick = { _ -> // TODO: Handle long position }, - onShortClick = { market -> + onShortClick = { _ -> // TODO: Handle short position }, onShowTradingGuide = onShowTradingGuide ) } + } + + val tabCount = tabs.size + + val pagerState = rememberPagerState( + initialPage = initialTabIndex.coerceIn(0, (tabCount - 1).coerceAtLeast(0)), + pageCount = { tabCount }, ) + // When SwapContent requests switching to Limit tab, animate to it + LaunchedEffect(switchToLimitRequested.value) { + if (switchToLimitRequested.value) { + coroutineScope.launch { + val target = (1).coerceAtMost((tabCount - 1).coerceAtLeast(0)) + pagerState.animateScrollToPage(target) + } + switchToLimitRequested.value = false + } + } + ModalBottomSheetLayout( sheetState = bottomSheetState, scrimColor = Color.Black.copy(alpha = 0.6f), diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 22b6d19e34..ce781d18cf 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2223,4 +2223,13 @@ 支撑当前仓位,抵扣浮动亏损。 当投入资金不足以支撑当前仓位时,仓位将被系统强制平仓。价格剧烈波动可能会快速消耗投入资金。 永续合约 + 开仓 + 杠杆 + 选择代币 + 选择杠杆 + 订单价值 + 强平价格 + 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) + 做多 + 做空 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3e3d847f9..5363137f49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2279,4 +2279,13 @@ Support current position and offset floating losses. When investment is insufficient to support current position, the position will be forcibly liquidated by the system. Severe price volatility may rapidly consume investment. Perpetual + Open Position + Leverage + Select Token + Select Leverage + Order Value + Liquidation Price + Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) + Long + Short From 591d219fca8e65a89116d5c321938b13e51e966e Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 11 Feb 2026 12:16:33 +0800 Subject: [PATCH 004/105] Perpetual database --- .../one.mixin.android.db.PerpsDatabase/1.json | 241 ++++++++ .../perps/{MarketView.kt => PerpsMarket.kt} | 25 +- .../api/response/perps/PositionView.kt | 30 +- .../mixin/android/api/service/RouteService.kt | 10 +- .../one/mixin/android/db/PerpsDatabase.kt | 82 +++ .../mixin/android/db/perps/PerpsMarketDao.kt | 32 ++ .../android/db/perps/PerpsPositionDao.kt | 35 ++ .../java/one/mixin/android/di/PerpsModule.kt | 35 ++ .../ui/home/web3/trade/MarketDetailPage.kt | 8 +- .../home/web3/trade/MarketListBottomSheet.kt | 8 +- .../ui/home/web3/trade/OpenPositionPage.kt | 49 +- .../ui/home/web3/trade/PerpetualContent.kt | 35 +- .../ui/home/web3/trade/PerpetualViewModel.kt | 139 ++++- .../PerpsConfirmBottomSheetDialogFragment.kt | 533 ++++++++++++++++++ app/src/main/res/values/strings.xml | 9 + 15 files changed, 1230 insertions(+), 41 deletions(-) create mode 100644 app/schemas/one.mixin.android.db.PerpsDatabase/1.json rename app/src/main/java/one/mixin/android/api/response/perps/{MarketView.kt => PerpsMarket.kt} (58%) create mode 100644 app/src/main/java/one/mixin/android/db/PerpsDatabase.kt create mode 100644 app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt create mode 100644 app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt create mode 100644 app/src/main/java/one/mixin/android/di/PerpsModule.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json new file mode 100644 index 0000000000..9f950f8ac4 --- /dev/null +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -0,0 +1,241 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "dd689eed1f12b5a775f376f4bc52222a", + "entities": [ + { + "tableName": "perps_positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `market_symbol` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `liquidation_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`position_id`))", + "fields": [ + { + "fieldPath": "positionId", + "columnName": "position_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketSymbol", + "columnName": "market_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryPrice", + "columnName": "entry_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "margin", + "columnName": "margin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markPrice", + "columnName": "mark_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unrealizedPnl", + "columnName": "unrealized_pnl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roe", + "columnName": "roe", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "liquidationPrice", + "columnName": "liquidation_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "position_id" + ] + } + }, + { + "tableName": "perps_markets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `market` TEXT NOT NULL, `symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `maker_fee` TEXT NOT NULL, `taker_fee` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `quantity_increment` TEXT NOT NULL, `price_increment` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `change` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "fields": [ + { + "fieldPath": "marketId", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "market", + "columnName": "market", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markPrice", + "columnName": "mark_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fundingRate", + "columnName": "funding_rate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "makerFee", + "columnName": "maker_fee", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "takerFee", + "columnName": "taker_fee", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minOrderSize", + "columnName": "min_order_size", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxOrderSize", + "columnName": "max_order_size", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minOrderValue", + "columnName": "min_order_value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quantityIncrement", + "columnName": "quantity_increment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priceIncrement", + "columnName": "price_increment", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "last", + "columnName": "last", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volume", + "columnName": "volume", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "change", + "columnName": "change", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "market_id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dd689eed1f12b5a775f376f4bc52222a')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/MarketView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt similarity index 58% rename from app/src/main/java/one/mixin/android/api/response/perps/MarketView.kt rename to app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index e215ac54f8..9ef449b8f1 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/MarketView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -1,42 +1,65 @@ package one.mixin.android.api.response.perps +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey import com.google.gson.annotations.SerializedName -data class MarketView( +@Entity(tableName = "perps_markets") +data class PerpsMarket( + @PrimaryKey @SerializedName("market_id") + @ColumnInfo(name = "market_id") val marketId: String, @SerializedName("market") + @ColumnInfo(name = "market") val market: String, @SerializedName("symbol") + @ColumnInfo(name = "symbol") val symbol: String, @SerializedName("mark_price") + @ColumnInfo(name = "mark_price") val markPrice: String, @SerializedName("funding_rate") + @ColumnInfo(name = "funding_rate") val fundingRate: String, @SerializedName("maker_fee") + @ColumnInfo(name = "maker_fee") val makerFee: String, @SerializedName("taker_fee") + @ColumnInfo(name = "taker_fee") val takerFee: String, @SerializedName("min_order_size") + @ColumnInfo(name = "min_order_size") val minOrderSize: String, @SerializedName("max_order_size") + @ColumnInfo(name = "max_order_size") val maxOrderSize: String, @SerializedName("min_order_value") + @ColumnInfo(name = "min_order_value") val minOrderValue: String, @SerializedName("quantity_increment") + @ColumnInfo(name = "quantity_increment") val quantityIncrement: String, @SerializedName("price_increment") + @ColumnInfo(name = "price_increment") val priceIncrement: String, @SerializedName("last") + @ColumnInfo(name = "last") val last: String, @SerializedName("volume") + @ColumnInfo(name = "volume") val volume: String, @SerializedName("leverage") + @ColumnInfo(name = "leverage") val leverage: Int, @SerializedName("icon_url") + @ColumnInfo(name = "icon_url") val iconUrl: String, @SerializedName("change") + @ColumnInfo(name = "change") val change: String, @SerializedName("updated_at") + @ColumnInfo(name = "updated_at") val updatedAt: String ) diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt index d451d80e2d..6990552576 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt @@ -1,30 +1,56 @@ package one.mixin.android.api.response.perps +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey import com.google.gson.annotations.SerializedName -data class PositionView( +@Entity(tableName = "perps_positions") +data class PerpsPosition( + @PrimaryKey @SerializedName("position_id") + @ColumnInfo(name = "position_id") val positionId: String, @SerializedName("market_symbol") + @ColumnInfo(name = "market_symbol") val marketSymbol: String, @SerializedName("side") + @ColumnInfo(name = "side") val side: String, @SerializedName("quantity") + @ColumnInfo(name = "quantity") val quantity: String, @SerializedName("entry_price") + @ColumnInfo(name = "entry_price") val entryPrice: String, @SerializedName("margin") + @ColumnInfo(name = "margin") val margin: String, @SerializedName("leverage") + @ColumnInfo(name = "leverage") val leverage: Int, @SerializedName("state") + @ColumnInfo(name = "state") val state: String, @SerializedName("mark_price") + @ColumnInfo(name = "mark_price") val markPrice: String, @SerializedName("unrealized_pnl") + @ColumnInfo(name = "unrealized_pnl") val unrealizedPnl: String, @SerializedName("roe") - val roe: String + @ColumnInfo(name = "roe") + val roe: String, + @ColumnInfo(name = "wallet_id") + val walletId: String = "", + @ColumnInfo(name = "market_id") + val marketId: String = "", + @ColumnInfo(name = "liquidation_price") + val liquidationPrice: String = "", + @ColumnInfo(name = "created_at") + val createdAt: String = "", + @ColumnInfo(name = "updated_at") + val updatedAt: String = "" ) data class PositionHistoryView( 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 00d3df9e13..b5788dab0d 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 @@ -61,9 +61,9 @@ import one.mixin.android.api.request.perps.OpenOrderRequest import one.mixin.android.api.request.perps.OpenOrderResponse import one.mixin.android.api.request.perps.CloseOrderRequest import one.mixin.android.api.request.perps.CloseOrderResponse -import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.perps.CandleView -import one.mixin.android.api.response.perps.PositionView +import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.api.response.perps.PositionHistoryView import retrofit2.http.Query @@ -358,12 +358,12 @@ interface RouteService { suspend fun getPerpsMarkets( @Query("offset") offset: Int = 0, @Query("limit") limit: Int = 20 - ): MixinResponse> + ): MixinResponse> @GET("perps/market") suspend fun getPerpsMarket( @Query("market_id") marketId: String - ): MixinResponse + ): MixinResponse @GET("perps/markets/candles") suspend fun getPerpsCandles( @@ -384,7 +384,7 @@ interface RouteService { @GET("perps/positions") suspend fun getPerpsPositions( @Query("wallet_id") walletId: String - ): MixinResponse> + ): MixinResponse> @GET("perps/positions/history") suspend fun getPerpsPositionHistory( diff --git a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt new file mode 100644 index 0000000000..164ec34070 --- /dev/null +++ b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt @@ -0,0 +1,82 @@ +package one.mixin.android.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import one.mixin.android.Constants +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.db.perps.PerpsPositionDao +import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.util.SINGLE_DB_EXECUTOR +import one.mixin.android.util.database.dbDir +import java.io.File +import java.util.concurrent.Executors +import kotlin.math.max +import kotlin.math.min + +@Database( + entities = [ + PerpsPosition::class, + PerpsMarket::class, + ], + version = 1, +) +abstract class PerpsDatabase : RoomDatabase() { + companion object { + private var INSTANCE: PerpsDatabase? = null + private val lock = Any() + + fun getDatabase(context: Context): PerpsDatabase { + synchronized(lock) { + if (INSTANCE == null) { + val dir = dbDir(context) + val builder = Room.databaseBuilder( + context, + PerpsDatabase::class.java, + File(dir, "perps.db").absolutePath, + ).openHelperFactory( + MixinOpenHelperFactory( + FrameworkSQLiteOpenHelperFactory(), + listOf( + object : MixinCorruptionCallback { + override fun onCorruption(database: SupportSQLiteDatabase) { + val e = IllegalStateException("Perps database is corrupted") + one.mixin.android.util.reportException(e) + } + }, + ), + ), + ).addCallback( + object : Callback() { + override fun onOpen(db: SupportSQLiteDatabase) { + super.onOpen(db) + db.execSQL("PRAGMA synchronous = NORMAL") + } + }, + ).enableMultiInstanceInvalidation() + .setQueryExecutor( + Executors.newFixedThreadPool( + max(2, min(Runtime.getRuntime().availableProcessors() - 1, 4)), + ), + ) + .setTransactionExecutor(SINGLE_DB_EXECUTOR) + INSTANCE = builder.build() + } + } + return INSTANCE as PerpsDatabase + } + } + + abstract fun perpsPositionDao(): PerpsPositionDao + abstract fun perpsMarketDao(): PerpsMarketDao + + override fun close() { + super.close() + INSTANCE = null + } +} diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt new file mode 100644 index 0000000000..4aa0b222af --- /dev/null +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt @@ -0,0 +1,32 @@ +package one.mixin.android.db.perps + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.db.BaseDao + +@Dao +interface PerpsMarketDao : BaseDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(market: PerpsMarket) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(markets: List) + + @Query("SELECT * FROM perps_markets ORDER BY CAST(volume AS REAL) DESC") + suspend fun getAllMarkets(): List + + @Query("SELECT * FROM perps_markets WHERE market_id = :marketId") + suspend fun getMarket(marketId: String): PerpsMarket? + + @Query("SELECT * FROM perps_markets WHERE symbol LIKE '%' || :query || '%' ORDER BY CAST(volume AS REAL) DESC") + suspend fun searchMarkets(query: String): List + + @Query("DELETE FROM perps_markets") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM perps_markets") + suspend fun getCount(): Int +} diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt new file mode 100644 index 0000000000..c3e5a2c62d --- /dev/null +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -0,0 +1,35 @@ +package one.mixin.android.db.perps + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.db.BaseDao + +@Dao +interface PerpsPositionDao : BaseDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(position: PerpsPosition) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(positions: List) + + @Query("SELECT * FROM perps_positions WHERE wallet_id = :walletId AND state = 'open' ORDER BY created_at DESC") + suspend fun getOpenPositions(walletId: String): List + + @Query("SELECT * FROM perps_positions WHERE wallet_id = :walletId ORDER BY created_at DESC LIMIT :limit") + suspend fun getPositionHistory(walletId: String, limit: Int = 100): List + + @Query("SELECT * FROM perps_positions WHERE position_id = :positionId") + suspend fun getPosition(positionId: String): PerpsPosition? + + @Query("UPDATE perps_positions SET state = :status, updated_at = :updatedAt WHERE position_id = :positionId") + suspend fun updateStatus(positionId: String, status: String, updatedAt: String) + + @Query("DELETE FROM perps_positions WHERE wallet_id = :walletId") + suspend fun deleteByWallet(walletId: String) + + @Query("SELECT SUM(CAST(unrealized_pnl AS REAL)) FROM perps_positions WHERE wallet_id = :walletId AND state = 'open'") + suspend fun getTotalUnrealizedPnl(walletId: String): Double? +} diff --git a/app/src/main/java/one/mixin/android/di/PerpsModule.kt b/app/src/main/java/one/mixin/android/di/PerpsModule.kt new file mode 100644 index 0000000000..cb4ff35fae --- /dev/null +++ b/app/src/main/java/one/mixin/android/di/PerpsModule.kt @@ -0,0 +1,35 @@ +package one.mixin.android.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import one.mixin.android.db.PerpsDatabase +import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.db.perps.PerpsPositionDao +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PerpsModule { + + @Provides + @Singleton + fun providePerpsDatabase(@ApplicationContext context: Context): PerpsDatabase { + return PerpsDatabase.getDatabase(context) + } + + @Provides + @Singleton + fun providePerpsPositionDao(database: PerpsDatabase): PerpsPositionDao { + return database.perpsPositionDao() + } + + @Provides + @Singleton + fun providePerpsMarketDao(database: PerpsDatabase): PerpsMarketDao { + return database.perpsMarketDao() + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt index be7341f248..0e91d32387 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -46,7 +46,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R -import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences @@ -61,7 +61,7 @@ fun MarketDetailPage( ) { val context = LocalContext.current val viewModel = hiltViewModel() - var market by remember { mutableStateOf(null) } + var market by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var selectedTimeFrame by remember { mutableIntStateOf(0) } val coroutineScope = rememberCoroutineScope() @@ -213,7 +213,7 @@ fun MarketDetailPage( @Composable private fun MarketInfoCard( - market: MarketView, + market: PerpsMarket, onLearnClick: () -> Unit, ) { Column( @@ -321,7 +321,7 @@ private fun formatVolume(volume: String): String { @Composable private fun MarketDetailCard( - market: MarketView, + market: PerpsMarket, marketSymbol: String, selectedTimeFrame: Int, timeFrames: List, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt index 26bdc35553..dd23a7daf1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R -import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.numberFormatCompact @@ -33,8 +33,8 @@ import java.math.BigDecimal @Composable fun MarketListBottomSheetContent( - markets: List, - onMarketClick: (MarketView) -> Unit + markets: List, + onMarketClick: (PerpsMarket) -> Unit ) { Column( modifier = Modifier @@ -66,7 +66,7 @@ fun MarketListBottomSheetContent( @Composable private fun MarketListItem( - market: MarketView, + market: PerpsMarket, onClick: () -> Unit ) { val change = try { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index ed1b1e51ac..2d009ca820 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -19,6 +19,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.ModalBottomSheetLayout @@ -26,6 +28,8 @@ import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Slider import androidx.compose.material.SliderDefaults import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -48,11 +52,12 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch import one.mixin.android.R -import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.safe.TokenItem +import one.mixin.android.web3.js.Web3Signer import java.math.BigDecimal import kotlin.math.abs @@ -70,7 +75,7 @@ fun OpenPositionPage( val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) val tokenBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) - var market by remember { mutableStateOf(null) } + var market by remember { mutableStateOf(null) } var selectedToken by remember { mutableStateOf(null) } var availableTokens by remember { mutableStateOf>(emptyList()) } var usdtAmount by remember { mutableStateOf("") } @@ -188,12 +193,12 @@ fun OpenPositionPage( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - androidx.compose.material.TextField( + TextField( value = usdtAmount, onValueChange = { usdtAmount = it }, modifier = Modifier.weight(1f), placeholder = { Text("0.00") }, - colors = androidx.compose.material.TextFieldDefaults.textFieldColors( + colors = TextFieldDefaults.textFieldColors( backgroundColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent @@ -376,16 +381,44 @@ fun OpenPositionPage( Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(16.dp)) - androidx.compose.material.Button( + Button( modifier = Modifier .padding(horizontal = 20.dp) .fillMaxWidth() .height(48.dp), onClick = { - // TODO: Open position + val token = selectedToken ?: return@Button + val amount = usdtAmount.toBigDecimalOrNull() ?: return@Button + + if (amount <= BigDecimal.ZERO) return@Button + + val m = market ?: return@Button + val walletId = Web3Signer.currentWalletId + if (walletId.isEmpty()) return@Button + + val activity = context as? androidx.fragment.app.FragmentActivity ?: return@Button + + val wallet = Web3Signer.currentWalletId + val walletName = "Privacy Wallet" + + PerpsConfirmBottomSheetDialogFragment.newInstance( + marketId = marketId, + marketSymbol = marketSymbol, + isLong = isLong, + amount = amount.toPlainString(), + leverage = leverage.toInt(), + entryPrice = m.markPrice, + liquidationPrice = calculateLiquidationPrice(m.markPrice, leverage, isLong), + tokenSymbol = token.symbol, + tokenIcon = token.iconUrl ?: "", + tokenAssetId = token.assetId, + walletName = walletName + ).setOnDone { + onBack() + }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) }, enabled = usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO, - colors = androidx.compose.material.ButtonDefaults.outlinedButtonColors( + colors = ButtonDefaults.outlinedButtonColors( backgroundColor = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { if (isLong) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed } else { @@ -393,7 +426,7 @@ fun OpenPositionPage( } ), shape = RoundedCornerShape(32.dp), - elevation = androidx.compose.material.ButtonDefaults.elevation( + elevation = ButtonDefaults.elevation( pressedElevation = 0.dp, defaultElevation = 0.dp, hoveredElevation = 0.dp, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index ef917b2200..2409e96dd1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -48,7 +48,7 @@ import kotlinx.coroutines.launch import one.mixin.android.compose.CoilImage import one.mixin.android.Constants import one.mixin.android.R -import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.numberFormatCompact import one.mixin.android.extension.openUrl @@ -58,17 +58,19 @@ import java.math.BigDecimal @OptIn(ExperimentalMaterialApi::class) @Composable fun PerpetualContent( - onLongClick: (MarketView) -> Unit, - onShortClick: (MarketView) -> Unit, + onLongClick: (PerpsMarket) -> Unit, + onShortClick: (PerpsMarket) -> Unit, onShowTradingGuide: () -> Unit ) { val context = LocalContext.current val viewModel = hiltViewModel() val coroutineScope = rememberCoroutineScope() - var markets by remember { mutableStateOf>(emptyList()) } + var markets by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } + var openPositionsCount by remember { mutableStateOf(0) } + var totalPnl by remember { mutableStateOf(0.0) } val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) var isLongMode by remember { mutableStateOf(true) } @@ -84,6 +86,17 @@ fun PerpetualContent( isLoading = false } ) + + val walletId = one.mixin.android.web3.js.Web3Signer.currentWalletId + if (walletId.isNotEmpty()) { + viewModel.getOpenPositions(walletId) { positions -> + openPositionsCount = positions.size + } + + viewModel.getTotalUnrealizedPnl(walletId) { pnl -> + totalPnl = pnl + } + } } ModalBottomSheetLayout( @@ -127,7 +140,7 @@ fun PerpetualContent( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "$0.00", + text = "$${String.format("%.2f", totalPnl)}", fontSize = 18.sp, fontWeight = FontWeight.W600, color = MixinAppTheme.colors.textPrimary, @@ -135,15 +148,15 @@ fun PerpetualContent( Spacer(modifier = Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "$0.00", + text = "$${String.format("%.2f", totalPnl)}", fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, + color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "(0.0%)", + text = "(${if (totalPnl >= 0) "+" else ""}${String.format("%.1f", totalPnl)}%)", fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, + color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, ) } } @@ -164,7 +177,7 @@ fun PerpetualContent( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Open Positions(0)", + text = "Open Positions($openPositionsCount)", fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) @@ -328,7 +341,7 @@ fun PerpetualContent( } @Composable -fun MarketItem(market: MarketView, onClick: () -> Unit = {}) { +fun MarketItem(market: PerpsMarket, onClick: () -> Unit = {}) { val change = try { BigDecimal(market.change) } catch (e: Exception) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index 344e94877f..9a428d3ac1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -7,8 +7,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.api.response.perps.CandleView -import one.mixin.android.api.response.perps.MarketView +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.service.RouteService +import one.mixin.android.api.request.perps.OpenOrderRequest +import one.mixin.android.api.request.perps.OpenOrderResponse +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.db.perps.PerpsPositionDao +import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.vo.safe.TokenItem import timber.log.Timber import javax.inject.Inject @@ -16,15 +21,25 @@ import javax.inject.Inject @HiltViewModel class PerpetualViewModel @Inject constructor( private val routeService: RouteService, - private val tokenDao: one.mixin.android.db.TokenDao + private val tokenDao: one.mixin.android.db.TokenDao, + private val perpsPositionDao: PerpsPositionDao, + private val perpsMarketDao: PerpsMarketDao ) : ViewModel() { fun loadMarkets( - onSuccess: (List) -> Unit, + onSuccess: (List) -> Unit, onError: (String) -> Unit ) { viewModelScope.launch { try { + val cachedMarkets = withContext(Dispatchers.IO) { + perpsMarketDao.getAllMarkets() + } + + if (cachedMarkets.isNotEmpty()) { + onSuccess(cachedMarkets) + } + val response = withContext(Dispatchers.IO) { routeService.getPerpsMarkets(offset = 0, limit = 100) } @@ -32,23 +47,36 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { Timber.d("Perps markets loaded: ${data.size} markets") + + withContext(Dispatchers.IO) { + perpsMarketDao.insertAll(data) + } + onSuccess(data) } else { val error = "Failed to load markets: ${response.errorDescription}" Timber.e(error) - onError(error) + if (cachedMarkets.isEmpty()) { + onError(error) + } } } catch (e: Exception) { val error = "Error loading markets: ${e.message}" Timber.e(e, error) - onError(error) + + val cachedMarkets = withContext(Dispatchers.IO) { + perpsMarketDao.getAllMarkets() + } + if (cachedMarkets.isEmpty()) { + onError(error) + } } } } fun loadMarketDetail( marketId: String, - onSuccess: (MarketView) -> Unit, + onSuccess: (PerpsMarket) -> Unit, onError: (String) -> Unit ) { viewModelScope.launch { @@ -120,4 +148,103 @@ class PerpetualViewModel @Inject constructor( } } } + + fun openPerpsOrder( + assetId: String, + productId: String, + side: String, + amount: String, + leverage: Int, + walletId: String, + destination: String? = null, + marketSymbol: String, + entryPrice: String, + liquidationPrice: String, + onSuccess: (OpenOrderResponse) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val request = OpenOrderRequest( + assetId = assetId, + productId = productId, + side = side, + amount = amount, + leverage = leverage, + walletId = walletId, + destination = destination + ) + + val response = withContext(Dispatchers.IO) { + routeService.openPerpsOrder(request) + } + + val data = response.data + if (response.isSuccess && data != null) { + Timber.d("Perps order opened: ${data.orderId}") + + val position = PerpsPosition( + positionId = data.orderId, + walletId = walletId, + marketId = productId, + marketSymbol = marketSymbol, + side = side, + quantity = amount, + entryPrice = entryPrice, + margin = amount, + leverage = leverage, + state = "open", + markPrice = entryPrice, + unrealizedPnl = "0", + roe = "0", + liquidationPrice = liquidationPrice, + createdAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()), + updatedAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()) + ) + + withContext(Dispatchers.IO) { + perpsPositionDao.insert(position) + } + + onSuccess(data) + } else { + val error = "Failed to open perps order: ${response.errorDescription}" + Timber.e(error) + onError(error) + } + } catch (e: Exception) { + val error = "Error opening perps order: ${e.message}" + Timber.e(e, error) + onError(error) + } + } + } + + fun getOpenPositions(walletId: String, onSuccess: (List) -> Unit) { + viewModelScope.launch { + try { + val positions = withContext(Dispatchers.IO) { + perpsPositionDao.getOpenPositions(walletId) + } + onSuccess(positions) + } catch (e: Exception) { + Timber.e(e, "Error loading open positions") + onSuccess(emptyList()) + } + } + } + + fun getTotalUnrealizedPnl(walletId: String, onSuccess: (Double) -> Unit) { + viewModelScope.launch { + try { + val pnl = withContext(Dispatchers.IO) { + perpsPositionDao.getTotalUnrealizedPnl(walletId) ?: 0.0 + } + onSuccess(pnl) + } catch (e: Exception) { + Timber.e(e, "Error loading total PnL") + onSuccess(0.0) + } + } + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..712d9f7fbd --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt @@ -0,0 +1,533 @@ +package one.mixin.android.ui.home.web3.trade + +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 +import androidx.compose.foundation.background +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import one.mixin.android.Constants +import one.mixin.android.R +import one.mixin.android.api.request.perps.OpenOrderResponse +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute +import one.mixin.android.extension.composeDp +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.putLong +import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.updatePinCheck +import one.mixin.android.extension.withArgs +import one.mixin.android.session.Session +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.ui.common.PinInputBottomSheetDialogFragment +import one.mixin.android.ui.common.biometric.BiometricInfo +import one.mixin.android.ui.home.web3.components.ActionBottom +import one.mixin.android.ui.wallet.components.WalletLabel +import one.mixin.android.util.SystemUIManager +import one.mixin.android.vo.safe.TokenItem +import one.mixin.android.web3.js.Web3Signer +import timber.log.Timber +import java.math.BigDecimal +import kotlin.math.abs + +@AndroidEntryPoint +class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + + companion object { + const val TAG = "PerpsConfirmBottomSheetDialogFragment" + private const val ARGS_MARKET_ID = "args_market_id" + private const val ARGS_MARKET_SYMBOL = "args_market_symbol" + private const val ARGS_IS_LONG = "args_is_long" + private const val ARGS_AMOUNT = "args_amount" + private const val ARGS_LEVERAGE = "args_leverage" + private const val ARGS_ENTRY_PRICE = "args_entry_price" + private const val ARGS_LIQUIDATION_PRICE = "args_liquidation_price" + private const val ARGS_TOKEN_SYMBOL = "args_token_symbol" + private const val ARGS_TOKEN_ICON = "args_token_icon" + private const val ARGS_TOKEN_ASSET_ID = "args_token_asset_id" + private const val ARGS_WALLET_NAME = "args_wallet_name" + + fun newInstance( + marketId: String, + marketSymbol: String, + isLong: Boolean, + amount: String, + leverage: Int, + entryPrice: String, + liquidationPrice: String, + tokenSymbol: String, + tokenIcon: String, + tokenAssetId: String, + walletName: String + ): PerpsConfirmBottomSheetDialogFragment { + return PerpsConfirmBottomSheetDialogFragment().withArgs { + putString(ARGS_MARKET_ID, marketId) + putString(ARGS_MARKET_SYMBOL, marketSymbol) + putBoolean(ARGS_IS_LONG, isLong) + putString(ARGS_AMOUNT, amount) + putInt(ARGS_LEVERAGE, leverage) + putString(ARGS_ENTRY_PRICE, entryPrice) + putString(ARGS_LIQUIDATION_PRICE, liquidationPrice) + putString(ARGS_TOKEN_SYMBOL, tokenSymbol) + putString(ARGS_TOKEN_ICON, tokenIcon) + putString(ARGS_TOKEN_ASSET_ID, tokenAssetId) + putString(ARGS_WALLET_NAME, walletName) + } + } + } + + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + + private val viewModel by viewModels() + + enum class Step { + Pending, + Sending, + Done, + Error, + } + + private val marketId by lazy { requireNotNull(requireArguments().getString(ARGS_MARKET_ID)) } + private val marketSymbol by lazy { requireNotNull(requireArguments().getString(ARGS_MARKET_SYMBOL)) } + private val isLong by lazy { requireArguments().getBoolean(ARGS_IS_LONG) } + private val amount by lazy { requireNotNull(requireArguments().getString(ARGS_AMOUNT)) } + private val leverage by lazy { requireArguments().getInt(ARGS_LEVERAGE) } + private val entryPrice by lazy { requireNotNull(requireArguments().getString(ARGS_ENTRY_PRICE)) } + private val liquidationPrice by lazy { requireNotNull(requireArguments().getString(ARGS_LIQUIDATION_PRICE)) } + private val tokenSymbol by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_SYMBOL)) } + private val tokenIcon by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_ICON)) } + private val tokenAssetId by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_ASSET_ID)) } + private val walletName by lazy { requireNotNull(requireArguments().getString(ARGS_WALLET_NAME)) } + + private var step by mutableStateOf(Step.Pending) + private var errorInfo: String? by mutableStateOf(null) + + @Composable + override fun ComposeContent() { + MixinAppTheme { + Column( + modifier = Modifier + .clip(shape = RoundedCornerShape(topStart = 8.composeDp, topEnd = 8.composeDp)) + .fillMaxWidth() + .fillMaxHeight() + .background(MixinAppTheme.colors.background), + ) { + WalletLabel(walletName = walletName, isWeb3 = true) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = true), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(modifier = Modifier.height(50.dp)) + when (step) { + Step.Sending -> { + CircularProgressIndicator( + modifier = Modifier.size(70.dp), + color = MixinAppTheme.colors.accent, + ) + } + Step.Error -> { + androidx.compose.material.Icon( + modifier = Modifier.size(70.dp), + painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_transfer_status_failed), + contentDescription = null, + tint = Color.Unspecified, + ) + } + Step.Done -> { + androidx.compose.material.Icon( + modifier = Modifier.size(70.dp), + painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_transfer_status_success), + contentDescription = null, + tint = Color.Unspecified, + ) + } + else -> { + CoilImage( + model = tokenIcon, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(70.dp) + .clip(CircleShape) + ) + } + } + Box(modifier = Modifier.height(20.dp)) + Text( + text = stringResource( + id = when (step) { + Step.Pending -> R.string.Perpetual + Step.Done -> R.string.web3_sending_success + Step.Error -> R.string.swap_failed + Step.Sending -> R.string.Sending + } + ), + style = TextStyle( + color = MixinAppTheme.colors.textPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.W600, + ), + ) + Box(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier.padding(horizontal = 24.dp), + text = errorInfo ?: "$marketSymbol - USD", + textAlign = TextAlign.Center, + style = TextStyle( + color = if (errorInfo != null) MixinAppTheme.colors.tipError else MixinAppTheme.colors.textMinor, + fontSize = 14.sp, + fontWeight = FontWeight.W400, + ), + maxLines = 3, + minLines = 3, + ) + Box(modifier = Modifier.height(10.dp)) + + Box( + modifier = Modifier + .height(10.dp) + .fillMaxWidth() + .background(MixinAppTheme.colors.backgroundWindow), + ) + Box(modifier = Modifier.height(20.dp)) + + PerpsInfoItem( + title = stringResource(R.string.Perpetual_Direction).uppercase(), + value = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${leverage}x" + ) + Box(modifier = Modifier.height(20.dp)) + + ProfitLossInfo( + amount = amount, + leverage = leverage, + isLong = isLong + ) + Box(modifier = Modifier.height(20.dp)) + + PerpsInfoItem( + title = stringResource(R.string.Amount).uppercase() + " (Isolated)", + value = "$amount $tokenSymbol" + ) + Box(modifier = Modifier.height(20.dp)) + + PerpsInfoItem( + title = "Entry Price".uppercase(), + value = "$$entryPrice" + ) + Box(modifier = Modifier.height(20.dp)) + + PerpsInfoItem( + title = stringResource(R.string.Liquidation_Price).uppercase(), + value = "$$liquidationPrice", + subValue = calculateLiquidationPercentage(entryPrice, liquidationPrice, isLong) + ) + Box(modifier = Modifier.height(20.dp)) + + PerpsInfoItem( + title = stringResource(R.string.Receiver).uppercase(), + value = "Mixin Futures (7000105155)" + ) + Box(modifier = Modifier.height(20.dp)) + + PerpsInfoItem( + title = stringResource(R.string.Sender).uppercase(), + value = walletName + ) + Box(modifier = Modifier.height(16.dp)) + } + + Box(modifier = Modifier.fillMaxWidth()) { + when (step) { + Step.Done -> { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .background(MixinAppTheme.colors.background) + .padding(20.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Button( + onClick = { + onDoneAction?.invoke() + dismiss() + }, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MixinAppTheme.colors.accent, + ), + shape = RoundedCornerShape(20.dp), + contentPadding = PaddingValues(horizontal = 36.dp, vertical = 11.dp), + ) { + Text(text = stringResource(id = R.string.Done), color = Color.White) + } + } + } + Step.Error -> { + ActionBottom( + modifier = Modifier.align(Alignment.BottomCenter), + cancelTitle = stringResource(R.string.Cancel), + confirmTitle = stringResource(id = R.string.Retry), + cancelAction = { dismiss() }, + confirmAction = { showPin() }, + ) + } + Step.Pending -> { + ActionBottom( + modifier = Modifier.align(Alignment.BottomCenter), + cancelTitle = stringResource(R.string.Cancel), + confirmTitle = stringResource(id = R.string.Confirm), + cancelAction = { dismiss() }, + confirmAction = { showPin() }, + ) + } + Step.Sending -> {} + } + } + Box(modifier = Modifier.height(36.dp)) + } + } + } + + @Composable + private fun PerpsInfoItem( + title: String, + value: String, + subValue: String? = null + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = title, + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + maxLines = 1, + ) + Box(modifier = Modifier.height(4.dp)) + Text( + text = value, + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.W400 + ) + subValue?.let { + Box(modifier = Modifier.height(4.dp)) + Text( + text = it, + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + ) + } + } + } + + @Composable + private fun ProfitLossInfo( + amount: String, + leverage: Int, + isLong: Boolean + ) { + val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO + val profitPercent = 1.0 * leverage + val profitAmount = amountValue * BigDecimal(profitPercent / 100) + val lossPercent = 100.0 / leverage + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = "价格${if (isLong) "上涨" else "下跌"} 1% → 盈利 ${String.format("%.1f", profitPercent)}% (+$${String.format("%.2f", profitAmount)})", + color = MixinAppTheme.colors.walletGreen, + fontSize = 14.sp, + ) + Box(modifier = Modifier.height(4.dp)) + Text( + text = "价格${if (isLong) "下跌" else "上涨"} ${String.format("%.2f", lossPercent)}% → 亏损 -$${amount}", + color = MixinAppTheme.colors.walletRed, + fontSize = 14.sp, + ) + } + } + + private fun calculateLiquidationPercentage(entryPrice: String, liquidationPrice: String, isLong: Boolean): String { + return try { + val entry = BigDecimal(entryPrice) + val liquidation = BigDecimal(liquidationPrice) + val diff = if (isLong) { + (entry - liquidation).divide(entry, 4, java.math.RoundingMode.HALF_UP) * BigDecimal(100) + } else { + (liquidation - entry).divide(entry, 4, java.math.RoundingMode.HALF_UP) * BigDecimal(100) + } + "价格${if (isLong) "下跌" else "上涨"} ${String.format("%.2f", diff)}% → 亏损 -$${amount}" + } catch (e: Exception) { + "" + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + private var onDoneAction: (() -> Unit)? = null + private var onDestroyAction: (() -> Unit)? = null + + fun setOnDone(callback: () -> Unit): PerpsConfirmBottomSheetDialogFragment { + onDoneAction = callback + return this + } + + fun setOnDestroy(callback: () -> Unit): PerpsConfirmBottomSheetDialogFragment { + onDestroyAction = callback + return this + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + onDestroyAction?.invoke() + } + + private fun showPin() { + PinInputBottomSheetDialogFragment.newInstance(biometricInfo = getBiometricInfo(), from = 1) + .setOnPinComplete { pin -> + lifecycleScope.launch( + CoroutineExceptionHandler { _, error -> + handleException(error) + }, + ) { + doAfterPinComplete(pin) + } + }.showNow(parentFragmentManager, PinInputBottomSheetDialogFragment.TAG) + } + + private fun doAfterPinComplete(pin: String) = lifecycleScope.launch(Dispatchers.IO) { + try { + step = Step.Sending + val walletId = Web3Signer.currentWalletId + if (walletId.isEmpty()) { + errorInfo = "Wallet not found" + step = Step.Error + return@launch + } + + viewModel.openPerpsOrder( + assetId = tokenAssetId, + productId = marketId, + side = if (isLong) "long" else "short", + amount = amount, + leverage = leverage, + walletId = walletId, + marketSymbol = marketSymbol, + entryPrice = entryPrice, + liquidationPrice = liquidationPrice, + onSuccess = { response -> + defaultSharedPreferences.putLong( + Constants.BIOMETRIC_PIN_CHECK, + System.currentTimeMillis(), + ) + context?.updatePinCheck() + step = Step.Done + }, + onError = { error -> + errorInfo = error + step = Step.Error + } + ) + } catch (e: Exception) { + handleException(e) + } + } + + private fun handleException(t: Throwable) { + Timber.e(t) + errorInfo = t.message ?: t.toString() + step = Step.Error + } + + fun getBiometricInfo() = BiometricInfo( + getString(R.string.Verify_by_Biometric), + "", + "", + ) + + override fun dismiss() { + dismissAllowingStateLoss() + } + + override fun showError(error: String) { + errorInfo = error + step = Step.Error + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5363137f49..5cc25df23f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2288,4 +2288,13 @@ Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) Long Short + Entry Price + Isolated + Mixin Futures + Wallet not found + How perps works? + Learn how to trade perps + 24H VOLUME + Open Interest + Funding Rate From d79f6f68aa423a70b76b90eb2ec6a38bd198265d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Feb 2026 12:34:02 +0800 Subject: [PATCH 005/105] Open position --- .../android/job/RefreshPerpsPositionsJob.kt | 85 ++++++++++++++ .../ui/home/web3/trade/OpenPositionPage.kt | 43 ++++--- .../ui/home/web3/trade/PerpetualViewModel.kt | 18 ++- .../ui/home/web3/trade/PerpsActivity.kt | 17 +++ .../PerpsConfirmBottomSheetDialogFragment.kt | 109 +++++++++++++----- 5 files changed, 227 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt diff --git a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt new file mode 100644 index 0000000000..abd6e61621 --- /dev/null +++ b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt @@ -0,0 +1,85 @@ +package one.mixin.android.job + +import com.birbit.android.jobqueue.Params +import kotlinx.coroutines.runBlocking +import one.mixin.android.db.PerpsDatabase +import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.db.perps.PerpsPositionDao +import one.mixin.android.db.web3.vo.isWatch +import one.mixin.android.session.Session +import timber.log.Timber + +class RefreshPerpsPositionsJob( + private val walletId: String? = null +) : BaseJob(Params(PRIORITY_BACKGROUND).singleInstanceBy(GROUP).requireNetwork().persist()) { + companion object { + private const val serialVersionUID = 1L + const val GROUP = "RefreshPerpsPositionsJob" + } + + override fun onRun(): Unit = runBlocking { + val perpsDb = PerpsDatabase.getDatabase(applicationContext) + val positionDao = perpsDb.perpsPositionDao() + val marketDao = perpsDb.perpsMarketDao() + + if (walletId != null) { + refreshPositions(walletId, positionDao, marketDao) + } else { + val wallets = web3WalletDao.getAllWallets().filter { !it.isWatch() }.map { it.id }.toMutableSet() + Session.getAccountId()?.let { wallets.add(it) } + + wallets.forEach { wId -> + refreshPositions(wId, positionDao, marketDao) + } + } + } + + private suspend fun refreshPositions( + walletId: String, + positionDao: PerpsPositionDao, + marketDao: PerpsMarketDao + ) { + try { + val response = routeService.getPerpsPositions(walletId = walletId) + + if (response.isSuccess && response.data != null) { + val positions = response.data!! + Timber.d("RefreshPerpsPositionsJob: Fetched ${positions.size} positions for wallet $walletId") + + if (positions.isNotEmpty()) { + positionDao.insertAll(positions) + + val marketIds = positions.map { it.marketId }.distinct() + if (marketIds.isNotEmpty()) { + refreshMarkets(marketIds, marketDao) + } + } + } else { + Timber.e("RefreshPerpsPositionsJob: Failed to fetch positions for wallet $walletId: ${response.errorDescription}") + } + } catch (e: Exception) { + Timber.e(e, "RefreshPerpsPositionsJob: Exception occurred while fetching positions for wallet $walletId") + } + } + + private suspend fun refreshMarkets( + marketIds: List, + marketDao: PerpsMarketDao + ) { + marketIds.forEach { marketId -> + try { + if (marketDao.getMarket(marketId) == null) { + val response = routeService.getPerpsMarket(marketId) + if (response.isSuccess && response.data != null) { + marketDao.insert(response.data!!) + Timber.d("RefreshPerpsPositionsJob: Successfully inserted market $marketId") + } else { + Timber.e("RefreshPerpsPositionsJob: Failed to fetch market $marketId: ${response.errorDescription}") + } + } + } catch (e: Exception) { + Timber.e(e, "RefreshPerpsPositionsJob: Exception occurred while fetching market $marketId") + } + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index 2d009ca820..b94e5e9ac6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -55,6 +55,7 @@ import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.safe.TokenItem import one.mixin.android.web3.js.Web3Signer @@ -393,29 +394,45 @@ fun OpenPositionPage( if (amount <= BigDecimal.ZERO) return@Button val m = market ?: return@Button - val walletId = Web3Signer.currentWalletId + val walletId = Session.getAccountId() ?: "" // Privacy Wallet if (walletId.isEmpty()) return@Button val activity = context as? androidx.fragment.app.FragmentActivity ?: return@Button - val wallet = Web3Signer.currentWalletId val walletName = "Privacy Wallet" - PerpsConfirmBottomSheetDialogFragment.newInstance( - marketId = marketId, - marketSymbol = marketSymbol, - isLong = isLong, + viewModel.openPerpsOrder( + assetId = token.assetId, + productId = marketId, + side = if (isLong) "long" else "short", amount = amount.toPlainString(), leverage = leverage.toInt(), + walletId = walletId, + marketSymbol = marketSymbol, entryPrice = m.markPrice, liquidationPrice = calculateLiquidationPrice(m.markPrice, leverage, isLong), - tokenSymbol = token.symbol, - tokenIcon = token.iconUrl ?: "", - tokenAssetId = token.assetId, - walletName = walletName - ).setOnDone { - onBack() - }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + onSuccess = { response -> + PerpsConfirmBottomSheetDialogFragment.newInstance( + marketId = marketId, + marketSymbol = marketSymbol, + isLong = isLong, + amount = amount.toPlainString(), + leverage = leverage.toInt(), + entryPrice = m.markPrice, + liquidationPrice = calculateLiquidationPrice(m.markPrice, leverage, isLong), + tokenSymbol = token.symbol, + tokenIcon = token.iconUrl ?: "", + tokenAssetId = token.assetId, + walletName = walletName, + payUrl = response.payUrl + ).setOnDone { + onBack() + }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + }, + onError = { error -> + // TODO: Show error toast or dialog + } + ) }, enabled = usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO, colors = ButtonDefaults.outlinedButtonColors( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index 9a428d3ac1..08671abbe3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -181,7 +181,7 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { - Timber.d("Perps order opened: ${data.orderId}") + Timber.d("Perps order opened: ${data.orderId}, payUrl: ${data.payUrl}") val position = PerpsPosition( positionId = data.orderId, @@ -193,7 +193,7 @@ class PerpetualViewModel @Inject constructor( entryPrice = entryPrice, margin = amount, leverage = leverage, - state = "open", + state = "pending", markPrice = entryPrice, unrealizedPnl = "0", roe = "0", @@ -247,4 +247,18 @@ class PerpetualViewModel @Inject constructor( } } } + + fun loadOpenPositions(walletId: String, onSuccess: (List) -> Unit) { + viewModelScope.launch { + try { + val positions = withContext(Dispatchers.IO) { + perpsPositionDao.getOpenPositions(walletId) + } + onSuccess(positions) + } catch (e: Exception) { + Timber.e(e, "Error loading open positions") + onSuccess(emptyList()) + } + } + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt index 7a73a44331..7a1ab06076 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt @@ -6,11 +6,19 @@ import android.os.Bundle import androidx.activity.compose.setContent import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.job.MixinJobManager +import one.mixin.android.job.RefreshPerpsPositionsJob +import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseActivity +import one.mixin.android.web3.js.Web3Signer +import javax.inject.Inject @AndroidEntryPoint class PerpsActivity : BaseActivity() { + @Inject + lateinit var jobManager: MixinJobManager + companion object { private const val EXTRA_MARKET_ID = "extra_market_id" private const val EXTRA_MARKET_SYMBOL = "extra_market_symbol" @@ -48,6 +56,8 @@ class PerpsActivity : BaseActivity() { val mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_DETAIL val isLong = intent.getBooleanExtra(EXTRA_IS_LONG, true) + refreshPositions() + setContent { MixinAppTheme { when (mode) { @@ -71,4 +81,11 @@ class PerpsActivity : BaseActivity() { } } } + + private fun refreshPositions() { + val walletId = Session.getAccountId() + walletId?.let { + jobManager.addJobInBackground(RefreshPerpsPositionsJob(it)) + } + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt index 712d9f7fbd..1ccec10e62 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt @@ -3,6 +3,7 @@ package one.mixin.android.ui.home.web3.trade import android.annotation.SuppressLint import android.app.Dialog import android.content.DialogInterface +import android.net.Uri import android.os.Bundle import android.view.Gravity import android.view.View @@ -65,14 +66,18 @@ import one.mixin.android.extension.withArgs import one.mixin.android.session.Session import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment import one.mixin.android.ui.common.PinInputBottomSheetDialogFragment +import one.mixin.android.ui.common.UtxoConsolidationBottomSheetDialogFragment import one.mixin.android.ui.common.biometric.BiometricInfo +import one.mixin.android.ui.common.biometric.buildTransferBiometricItem import one.mixin.android.ui.home.web3.components.ActionBottom import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager import one.mixin.android.vo.safe.TokenItem +import one.mixin.android.vo.toUser import one.mixin.android.web3.js.Web3Signer import timber.log.Timber import java.math.BigDecimal +import java.util.UUID import kotlin.math.abs @AndroidEntryPoint @@ -91,6 +96,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private const val ARGS_TOKEN_ICON = "args_token_icon" private const val ARGS_TOKEN_ASSET_ID = "args_token_asset_id" private const val ARGS_WALLET_NAME = "args_wallet_name" + private const val ARGS_PAY_URL = "args_pay_url" fun newInstance( marketId: String, @@ -103,7 +109,8 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm tokenSymbol: String, tokenIcon: String, tokenAssetId: String, - walletName: String + walletName: String, + payUrl: String? ): PerpsConfirmBottomSheetDialogFragment { return PerpsConfirmBottomSheetDialogFragment().withArgs { putString(ARGS_MARKET_ID, marketId) @@ -117,6 +124,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm putString(ARGS_TOKEN_ICON, tokenIcon) putString(ARGS_TOKEN_ASSET_ID, tokenAssetId) putString(ARGS_WALLET_NAME, walletName) + putString(ARGS_PAY_URL, payUrl) } } } @@ -147,6 +155,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } private val viewModel by viewModels() + private val bottomViewModel by viewModels() enum class Step { Pending, @@ -166,6 +175,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private val tokenIcon by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_ICON)) } private val tokenAssetId by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_ASSET_ID)) } private val walletName by lazy { requireNotNull(requireArguments().getString(ARGS_WALLET_NAME)) } + private val payUrl by lazy { requireArguments().getString(ARGS_PAY_URL) } private var step by mutableStateOf(Step.Pending) private var errorInfo: String? by mutableStateOf(null) @@ -475,36 +485,17 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private fun doAfterPinComplete(pin: String) = lifecycleScope.launch(Dispatchers.IO) { try { step = Step.Sending - val walletId = Web3Signer.currentWalletId - if (walletId.isEmpty()) { - errorInfo = "Wallet not found" - step = Step.Error - return@launch + + if (payUrl != null) { + handlePayment(payUrl!!, pin) + } else { + defaultSharedPreferences.putLong( + Constants.BIOMETRIC_PIN_CHECK, + System.currentTimeMillis(), + ) + context?.updatePinCheck() + step = Step.Done } - - viewModel.openPerpsOrder( - assetId = tokenAssetId, - productId = marketId, - side = if (isLong) "long" else "short", - amount = amount, - leverage = leverage, - walletId = walletId, - marketSymbol = marketSymbol, - entryPrice = entryPrice, - liquidationPrice = liquidationPrice, - onSuccess = { response -> - defaultSharedPreferences.putLong( - Constants.BIOMETRIC_PIN_CHECK, - System.currentTimeMillis(), - ) - context?.updatePinCheck() - step = Step.Done - }, - onError = { error -> - errorInfo = error - step = Step.Error - } - ) } catch (e: Exception) { handleException(e) } @@ -522,6 +513,64 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm "", ) + private suspend fun handlePayment(payUrl: String, pin: String) { + try { + val uri = Uri.parse(payUrl) + + val assetId = requireNotNull(uri.getQueryParameter("asset")) + val payAmount = requireNotNull(uri.getQueryParameter("amount")) + val receiverId = requireNotNull(uri.lastPathSegment) + val memo = uri.getQueryParameter("memo") + val traceId = uri.getQueryParameter("trace") ?: UUID.randomUUID().toString() + + val consolidationAmount = bottomViewModel.checkUtxoSufficiency(assetId, payAmount) + val token = bottomViewModel.findAssetItemById(assetId) + + if (consolidationAmount != null && token != null) { + UtxoConsolidationBottomSheetDialogFragment.newInstance( + buildTransferBiometricItem( + Session.getAccount()!!.toUser(), + token, + consolidationAmount, + UUID.randomUUID().toString(), + null, + null + ) + ).show(parentFragmentManager, UtxoConsolidationBottomSheetDialogFragment.TAG) + step = Step.Pending + return + } else if (token == null) { + errorInfo = getString(R.string.Data_error) + step = Step.Error + return + } + + val paymentResponse = bottomViewModel.kernelTransaction( + assetId, + listOf(receiverId), + 1.toByte(), + payAmount, + pin, + traceId, + memo + ) + + if (paymentResponse.isSuccess) { + defaultSharedPreferences.putLong( + Constants.BIOMETRIC_PIN_CHECK, + System.currentTimeMillis(), + ) + context?.updatePinCheck() + step = Step.Done + } else { + errorInfo = paymentResponse.errorDescription + step = Step.Error + } + } catch (e: Exception) { + handleException(e) + } + } + override fun dismiss() { dismissAllowingStateLoss() } From ab20a8dc46d99baaf9b89ed741d40fffab883c66 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Feb 2026 13:44:11 +0800 Subject: [PATCH 006/105] Close position --- .../mixin/android/api/service/RouteService.kt | 5 + .../ui/home/web3/trade/MarketDetailPage.kt | 129 +++-- .../ui/home/web3/trade/PerpetualViewModel.kt | 88 ++++ .../PerpsCloseBottomSheetDialogFragment.kt | 474 ++++++++++++++++++ 4 files changed, 655 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt 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 b5788dab0d..f6dc2199a4 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 @@ -386,6 +386,11 @@ interface RouteService { @Query("wallet_id") walletId: String ): MixinResponse> + @GET("perps/positions/{id}") + suspend fun getPerpsPosition( + @Path("id") positionId: String + ): MixinResponse + @GET("perps/positions/history") suspend fun getPerpsPositionHistory( @Query("offset") offset: String? = null, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt index 0e91d32387..75fa22a776 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -47,9 +47,11 @@ import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal @@ -64,9 +66,12 @@ fun MarketDetailPage( var market by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var selectedTimeFrame by remember { mutableIntStateOf(0) } + var currentPosition by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val timeFrames = listOf("1h", "1d", "1w", "1M") + + val walletId = Session.getAccountId() ?: "" LaunchedEffect(marketId) { viewModel.loadMarketDetail( @@ -79,6 +84,12 @@ fun MarketDetailPage( isLoading = false } ) + + if (walletId.isNotEmpty()) { + viewModel.getPositionByMarket(walletId, marketId) { position -> + currentPosition = position + } + } } PageScaffold( @@ -138,24 +149,24 @@ fun MarketDetailPage( Spacer(modifier = Modifier.height(16.dp)) if (market != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { + if (currentPosition != null) { Button( modifier = Modifier - .weight(1f) + .fillMaxWidth() .height(48.dp), onClick = { - PerpsActivity.showOpenPosition( - context = context, - marketId = marketId, - marketSymbol = marketSymbol, - isLong = true - ) + val activity = context as? androidx.fragment.app.FragmentActivity ?: return@Button + val position = currentPosition ?: return@Button + + PerpsCloseBottomSheetDialogFragment.newInstance( + position = position, + walletName = "Privacy Wallet" + ).setOnDone { + currentPosition = null + }.show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) }, colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = MixinAppTheme.colors.walletGreen + backgroundColor = MixinAppTheme.colors.accent ), shape = RoundedCornerShape(32.dp), elevation = ButtonDefaults.elevation( @@ -166,42 +177,78 @@ fun MarketDetailPage( ) ) { Text( - text = "Long", + text = "Close Position", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White ) } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + modifier = Modifier + .weight(1f) + .height(48.dp), + onClick = { + PerpsActivity.showOpenPosition( + context = context, + marketId = marketId, + marketSymbol = marketSymbol, + isLong = true + ) + }, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MixinAppTheme.colors.walletGreen + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp + ) + ) { + Text( + text = "Long", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } - Button( - modifier = Modifier - .weight(1f) - .height(48.dp), - onClick = { - PerpsActivity.showOpenPosition( - context = context, - marketId = marketId, - marketSymbol = marketSymbol, - isLong = false + Button( + modifier = Modifier + .weight(1f) + .height(48.dp), + onClick = { + PerpsActivity.showOpenPosition( + context = context, + marketId = marketId, + marketSymbol = marketSymbol, + isLong = false + ) + }, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MixinAppTheme.colors.walletRed + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp ) - }, - colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = MixinAppTheme.colors.walletRed - ), - shape = RoundedCornerShape(32.dp), - elevation = ButtonDefaults.elevation( - pressedElevation = 0.dp, - defaultElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp - ) - ) { - Text( - text = "Short", - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) + ) { + Text( + text = "Short", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index 08671abbe3..67b17c99c5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -261,4 +261,92 @@ class PerpetualViewModel @Inject constructor( } } } + + fun getPositionByMarket(walletId: String, marketId: String, onSuccess: (PerpsPosition?) -> Unit) { + viewModelScope.launch { + try { + val positions = withContext(Dispatchers.IO) { + perpsPositionDao.getOpenPositions(walletId) + } + val position = positions.firstOrNull { it.marketId == marketId } + onSuccess(position) + } catch (e: Exception) { + Timber.e(e, "Error loading position by market") + onSuccess(null) + } + } + } + + fun closePerpsOrder( + positionId: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val request = one.mixin.android.api.request.perps.CloseOrderRequest( + positionId = positionId + ) + + val response = withContext(Dispatchers.IO) { + routeService.closePerpsOrder(request) + } + + if (response.isSuccess) { + Timber.d("Perps order closed: $positionId") + + withContext(Dispatchers.IO) { + perpsPositionDao.updateStatus( + positionId = positionId, + status = "closed", + updatedAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()) + ) + } + + onSuccess() + } else { + val error = "Failed to close perps order: ${response.errorDescription}" + Timber.e(error) + onError(error) + } + } catch (e: Exception) { + val error = "Error closing perps order: ${e.message}" + Timber.e(e, error) + onError(error) + } + } + } + + fun loadPositionDetail( + positionId: String, + onSuccess: (PerpsPosition) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + routeService.getPerpsPosition(positionId) + } + + val data = response.data + if (response.isSuccess && data != null) { + Timber.d("Position detail loaded: ${data.positionId}") + + withContext(Dispatchers.IO) { + perpsPositionDao.insert(data) + } + + onSuccess(data) + } else { + val error = "Failed to load position detail: ${response.errorDescription}" + Timber.e(error) + onError(error) + } + } catch (e: Exception) { + val error = "Error loading position detail: ${e.message}" + Timber.e(e, error) + onError(error) + } + } + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..37373c9e36 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt @@ -0,0 +1,474 @@ +package one.mixin.android.ui.home.web3.trade + +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 +import androidx.compose.foundation.background +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute +import one.mixin.android.extension.composeDp +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.getParcelableCompat +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.putLong +import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.updatePinCheck +import one.mixin.android.extension.withArgs +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.ui.common.PinInputBottomSheetDialogFragment +import one.mixin.android.ui.common.biometric.BiometricInfo +import one.mixin.android.ui.home.web3.components.ActionBottom +import one.mixin.android.ui.wallet.components.WalletLabel +import one.mixin.android.util.SystemUIManager +import timber.log.Timber +import java.math.BigDecimal + +@AndroidEntryPoint +class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + + companion object { + const val TAG = "PerpsCloseBottomSheetDialogFragment" + private const val ARGS_POSITION_ID = "args_position_id" + private const val ARGS_MARKET_SYMBOL = "args_market_symbol" + private const val ARGS_SIDE = "args_side" + private const val ARGS_QUANTITY = "args_quantity" + private const val ARGS_LEVERAGE = "args_leverage" + private const val ARGS_ENTRY_PRICE = "args_entry_price" + private const val ARGS_MARK_PRICE = "args_mark_price" + private const val ARGS_UNREALIZED_PNL = "args_unrealized_pnl" + private const val ARGS_ROE = "args_roe" + private const val ARGS_WALLET_NAME = "args_wallet_name" + + fun newInstance( + position: PerpsPosition, + walletName: String + ): PerpsCloseBottomSheetDialogFragment { + return PerpsCloseBottomSheetDialogFragment().withArgs { + putString(ARGS_POSITION_ID, position.positionId) + putString(ARGS_MARKET_SYMBOL, position.marketSymbol) + putString(ARGS_SIDE, position.side) + putString(ARGS_QUANTITY, position.quantity) + putInt(ARGS_LEVERAGE, position.leverage) + putString(ARGS_ENTRY_PRICE, position.entryPrice) + putString(ARGS_MARK_PRICE, position.markPrice) + putString(ARGS_UNREALIZED_PNL, position.unrealizedPnl) + putString(ARGS_ROE, position.roe) + putString(ARGS_WALLET_NAME, walletName) + } + } + } + + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + + private val viewModel by viewModels() + + enum class Step { + Pending, + Sending, + Done, + Error, + } + + private val positionId by lazy { requireNotNull(requireArguments().getString(ARGS_POSITION_ID)) } + private val marketSymbol by lazy { requireNotNull(requireArguments().getString(ARGS_MARKET_SYMBOL)) } + private val side by lazy { requireNotNull(requireArguments().getString(ARGS_SIDE)) } + private val quantity by lazy { requireNotNull(requireArguments().getString(ARGS_QUANTITY)) } + private val leverage by lazy { requireArguments().getInt(ARGS_LEVERAGE) } + private val entryPrice by lazy { requireNotNull(requireArguments().getString(ARGS_ENTRY_PRICE)) } + private val markPrice by lazy { requireNotNull(requireArguments().getString(ARGS_MARK_PRICE)) } + private val unrealizedPnl by lazy { requireNotNull(requireArguments().getString(ARGS_UNREALIZED_PNL)) } + private val roe by lazy { requireNotNull(requireArguments().getString(ARGS_ROE)) } + private val walletName by lazy { requireNotNull(requireArguments().getString(ARGS_WALLET_NAME)) } + + private var step by mutableStateOf(Step.Pending) + private var errorInfo: String? by mutableStateOf(null) + private var isLoading by mutableStateOf(true) + + private var latestMarkPrice by mutableStateOf(markPrice) + private var latestUnrealizedPnl by mutableStateOf(unrealizedPnl) + private var latestRoe by mutableStateOf(roe) + + @Composable + override fun ComposeContent() { + androidx.compose.runtime.LaunchedEffect(positionId) { + viewModel.loadPositionDetail( + positionId = positionId, + onSuccess = { position -> + latestMarkPrice = position.markPrice + latestUnrealizedPnl = position.unrealizedPnl + latestRoe = position.roe + isLoading = false + }, + onError = { error -> + Timber.e("Failed to load position detail: $error") + isLoading = false + } + ) + } + + MixinAppTheme { + Column( + modifier = Modifier + .clip(shape = RoundedCornerShape(topStart = 8.composeDp, topEnd = 8.composeDp)) + .fillMaxWidth() + .fillMaxHeight() + .background(MixinAppTheme.colors.background), + ) { + WalletLabel(walletName = walletName, isWeb3 = true) + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(weight = 1f, fill = true), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(modifier = Modifier.height(50.dp)) + when (step) { + Step.Sending -> { + CircularProgressIndicator( + modifier = Modifier.size(70.dp), + color = MixinAppTheme.colors.accent, + ) + } + Step.Error -> { + androidx.compose.material.Icon( + modifier = Modifier.size(70.dp), + painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_transfer_status_failed), + contentDescription = null, + tint = Color.Unspecified, + ) + } + Step.Done -> { + androidx.compose.material.Icon( + modifier = Modifier.size(70.dp), + painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_transfer_status_success), + contentDescription = null, + tint = Color.Unspecified, + ) + } + else -> { + Box( + modifier = Modifier + .size(70.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow), + contentAlignment = Alignment.Center + ) { + Text( + text = marketSymbol.take(3), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.accent + ) + } + } + } + Box(modifier = Modifier.height(20.dp)) + Text( + text = stringResource( + id = when (step) { + Step.Pending -> R.string.Perpetual + Step.Done -> R.string.web3_sending_success + Step.Error -> R.string.swap_failed + Step.Sending -> R.string.Sending + } + ), + style = TextStyle( + color = MixinAppTheme.colors.textPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.W600, + ), + ) + Box(modifier = Modifier.height(8.dp)) + Text( + modifier = Modifier.padding(horizontal = 24.dp), + text = errorInfo ?: "$marketSymbol - USD", + textAlign = TextAlign.Center, + style = TextStyle( + color = if (errorInfo != null) MixinAppTheme.colors.tipError else MixinAppTheme.colors.textMinor, + fontSize = 14.sp, + fontWeight = FontWeight.W400, + ), + maxLines = 3, + minLines = 3, + ) + Box(modifier = Modifier.height(10.dp)) + + Box( + modifier = Modifier + .height(10.dp) + .fillMaxWidth() + .background(MixinAppTheme.colors.backgroundWindow), + ) + Box(modifier = Modifier.height(20.dp)) + + val pnl = try { + BigDecimal(latestUnrealizedPnl) + } catch (e: Exception) { + BigDecimal.ZERO + } + val pnlColor = if (pnl >= BigDecimal.ZERO) { + MixinAppTheme.colors.walletGreen + } else { + MixinAppTheme.colors.walletRed + } + + val estimatedReceive = try { + val margin = BigDecimal(quantity) + margin + pnl + } catch (e: Exception) { + BigDecimal.ZERO + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = MixinAppTheme.colors.accent + ) + } + } else { + CloseInfoItem( + title = "Estimate Receive".uppercase(), + value = "${if (estimatedReceive >= BigDecimal.ZERO) "+" else ""}${String.format("%.2f", estimatedReceive)} USDT", + valueColor = if (estimatedReceive >= BigDecimal.ZERO) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, + subValue = "Ethereum" + ) + Box(modifier = Modifier.height(20.dp)) + + CloseInfoItem( + title = "PnL".uppercase(), + value = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} USDT (${latestRoe}%)", + valueColor = pnlColor + ) + Box(modifier = Modifier.height(20.dp)) + + CloseInfoItem( + title = stringResource(R.string.Receiver).uppercase(), + value = walletName + ) + Box(modifier = Modifier.height(20.dp)) + + CloseInfoItem( + title = stringResource(R.string.Sender).uppercase(), + value = "Mixin Futures (7000105155)" + ) + Box(modifier = Modifier.height(20.dp)) + + CloseInfoItem( + title = "Memo".uppercase(), + value = positionId + ) + } + Box(modifier = Modifier.height(16.dp)) + } + + Box(modifier = Modifier.fillMaxWidth()) { + when (step) { + Step.Done -> { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .background(MixinAppTheme.colors.background) + .padding(20.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Button( + onClick = { + onDoneAction?.invoke() + dismiss() + }, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MixinAppTheme.colors.accent, + ), + shape = RoundedCornerShape(20.dp), + contentPadding = PaddingValues(horizontal = 36.dp, vertical = 11.dp), + ) { + Text(text = stringResource(id = R.string.Done), color = Color.White) + } + } + } + Step.Error -> { + ActionBottom( + modifier = Modifier.align(Alignment.BottomCenter), + cancelTitle = stringResource(R.string.Cancel), + confirmTitle = stringResource(id = R.string.Retry), + cancelAction = { dismiss() }, + confirmAction = { closePosition() }, + ) + } + Step.Pending -> { + ActionBottom( + modifier = Modifier.align(Alignment.BottomCenter), + cancelTitle = stringResource(R.string.Cancel), + confirmTitle = stringResource(id = R.string.Confirm), + cancelAction = { dismiss() }, + confirmAction = { closePosition() }, + ) + } + Step.Sending -> {} + } + } + Box(modifier = Modifier.height(36.dp)) + } + } + } + + @Composable + private fun CloseInfoItem( + title: String, + value: String, + valueColor: Color = MixinAppTheme.colors.textPrimary, + subValue: String? = null + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = title, + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + maxLines = 1, + ) + Box(modifier = Modifier.height(4.dp)) + Text( + text = value, + color = valueColor, + fontSize = 16.sp, + fontWeight = FontWeight.W400 + ) + subValue?.let { + Box(modifier = Modifier.height(4.dp)) + Text( + text = it, + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + ) + } + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + private var onDoneAction: (() -> Unit)? = null + + fun setOnDone(callback: () -> Unit): PerpsCloseBottomSheetDialogFragment { + onDoneAction = callback + return this + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + } + + private fun closePosition() = lifecycleScope.launch(Dispatchers.IO) { + try { + step = Step.Sending + + viewModel.closePerpsOrder( + positionId = positionId, + onSuccess = { + step = Step.Done + }, + onError = { error -> + errorInfo = error + step = Step.Error + } + ) + } catch (e: Exception) { + handleException(e) + } + } + + private fun handleException(t: Throwable) { + Timber.e(t) + errorInfo = t.message ?: t.toString() + step = Step.Error + } + + override fun dismiss() { + dismissAllowingStateLoss() + } + + override fun showError(error: String) { + errorInfo = error + step = Step.Error + } +} From 0087f0d534f010668807c4343f4f6fffd1b6bf84 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 24 Feb 2026 14:22:24 +0800 Subject: [PATCH 007/105] Update --- .../one.mixin.android.db.PerpsDatabase/1.json | 21 ++----- .../api/response/perps/PositionView.kt | 10 ++-- .../android/job/RefreshPerpsPositionsJob.kt | 3 +- .../ui/home/web3/trade/OpenPositionPage.kt | 1 - .../ui/home/web3/trade/PerpetualViewModel.kt | 2 - .../PerpsCloseBottomSheetDialogFragment.kt | 56 ++++++++++++++----- .../PerpsConfirmBottomSheetDialogFragment.kt | 10 +--- 7 files changed, 57 insertions(+), 46 deletions(-) diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index 5b2623f720..6c4742499e 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "6b64d246d38e4b5830b4e68ca659a6f9", + "identityHash": "242ad9ad4f162c802672108bcbc4826d", "entities": [ { "tableName": "perps_positions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `market_symbol` TEXT, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `liquidation_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`position_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `market_symbol` TEXT, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, PRIMARY KEY(`position_id`))", "fields": [ { "fieldPath": "positionId", @@ -82,26 +82,17 @@ { "fieldPath": "walletId", "columnName": "wallet_id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "liquidationPrice", - "columnName": "liquidation_price", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "createdAt", "columnName": "created_at", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "updatedAt", "columnName": "updated_at", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" } ], "primaryKey": { @@ -234,7 +225,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6b64d246d38e4b5830b4e68ca659a6f9')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '242ad9ad4f162c802672108bcbc4826d')" ] } } \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt index 7a2fb9e3c4..fd3b6462df 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt @@ -45,13 +45,13 @@ data class PerpsPosition( @ColumnInfo(name = "roe") val roe: String, @ColumnInfo(name = "wallet_id") - val walletId: String = "", - @ColumnInfo(name = "liquidation_price") - val liquidationPrice: String = "", + val walletId: String? = null, + @SerializedName("created_at") @ColumnInfo(name = "created_at") - val createdAt: String = "", + val createdAt: String? = null, + @SerializedName("updated_at") @ColumnInfo(name = "updated_at") - val updatedAt: String = "" + val updatedAt: String? = null ) data class PositionHistoryView( diff --git a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt index 5b5587d766..c59a947635 100644 --- a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt +++ b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt @@ -2,6 +2,7 @@ package one.mixin.android.job import com.birbit.android.jobqueue.Params import kotlinx.coroutines.runBlocking +import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.db.PerpsDatabase import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.db.perps.PerpsPositionDao @@ -43,7 +44,7 @@ class RefreshPerpsPositionsJob( val response = routeService.getPerpsPositions(walletId = walletId) if (response.isSuccess && response.data != null) { - val positions = response.data!! + val positions = response.data!!.map { it.copy(walletId = walletId) } Timber.d("RefreshPerpsPositionsJob: Fetched ${positions.size} positions for wallet $walletId") if (positions.isNotEmpty()) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index b94e5e9ac6..2926e89c57 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -410,7 +410,6 @@ fun OpenPositionPage( walletId = walletId, marketSymbol = marketSymbol, entryPrice = m.markPrice, - liquidationPrice = calculateLiquidationPrice(m.markPrice, leverage, isLong), onSuccess = { response -> PerpsConfirmBottomSheetDialogFragment.newInstance( marketId = marketId, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index 3abedbda51..e0a6602de9 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -159,7 +159,6 @@ class PerpetualViewModel @Inject constructor( destination: String? = null, marketSymbol: String, entryPrice: String, - liquidationPrice: String, onSuccess: (OpenOrderResponse) -> Unit, onError: (String) -> Unit ) { @@ -197,7 +196,6 @@ class PerpetualViewModel @Inject constructor( markPrice = entryPrice, unrealizedPnl = "0", roe = "0", - liquidationPrice = liquidationPrice, createdAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()), updatedAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()) ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt index 37373c9e36..11505cf032 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt @@ -90,7 +90,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ): PerpsCloseBottomSheetDialogFragment { return PerpsCloseBottomSheetDialogFragment().withArgs { putString(ARGS_POSITION_ID, position.positionId) - putString(ARGS_MARKET_SYMBOL, position.marketSymbol) + putString(ARGS_MARKET_SYMBOL, position.marketSymbol ?: "") putString(ARGS_SIDE, position.side) putString(ARGS_QUANTITY, position.quantity) putInt(ARGS_LEVERAGE, position.leverage) @@ -137,27 +137,53 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Error, } - private val positionId by lazy { requireNotNull(requireArguments().getString(ARGS_POSITION_ID)) } - private val marketSymbol by lazy { requireNotNull(requireArguments().getString(ARGS_MARKET_SYMBOL)) } - private val side by lazy { requireNotNull(requireArguments().getString(ARGS_SIDE)) } - private val quantity by lazy { requireNotNull(requireArguments().getString(ARGS_QUANTITY)) } - private val leverage by lazy { requireArguments().getInt(ARGS_LEVERAGE) } - private val entryPrice by lazy { requireNotNull(requireArguments().getString(ARGS_ENTRY_PRICE)) } - private val markPrice by lazy { requireNotNull(requireArguments().getString(ARGS_MARK_PRICE)) } - private val unrealizedPnl by lazy { requireNotNull(requireArguments().getString(ARGS_UNREALIZED_PNL)) } - private val roe by lazy { requireNotNull(requireArguments().getString(ARGS_ROE)) } - private val walletName by lazy { requireNotNull(requireArguments().getString(ARGS_WALLET_NAME)) } + private val positionId by lazy { + requireNotNull(requireArguments().getString(ARGS_POSITION_ID)) { "positionId is null" } + } + private val marketSymbol by lazy { + requireArguments().getString(ARGS_MARKET_SYMBOL) ?: "Unknown" + } + private val side by lazy { + requireNotNull(requireArguments().getString(ARGS_SIDE)) { "side is null" } + } + private val quantity by lazy { + requireNotNull(requireArguments().getString(ARGS_QUANTITY)) { "quantity is null" } + } + private val leverage by lazy { + requireArguments().getInt(ARGS_LEVERAGE) + } + private val entryPrice by lazy { + requireNotNull(requireArguments().getString(ARGS_ENTRY_PRICE)) { "entryPrice is null" } + } + private val markPrice by lazy { + requireNotNull(requireArguments().getString(ARGS_MARK_PRICE)) { "markPrice is null" } + } + private val unrealizedPnl by lazy { + requireNotNull(requireArguments().getString(ARGS_UNREALIZED_PNL)) { "unrealizedPnl is null" } + } + private val roe by lazy { + requireNotNull(requireArguments().getString(ARGS_ROE)) { "roe is null" } + } + private val walletName by lazy { + requireNotNull(requireArguments().getString(ARGS_WALLET_NAME)) { "walletName is null" } + } private var step by mutableStateOf(Step.Pending) private var errorInfo: String? by mutableStateOf(null) private var isLoading by mutableStateOf(true) - private var latestMarkPrice by mutableStateOf(markPrice) - private var latestUnrealizedPnl by mutableStateOf(unrealizedPnl) - private var latestRoe by mutableStateOf(roe) + private var latestMarkPrice by mutableStateOf("") + private var latestUnrealizedPnl by mutableStateOf("") + private var latestRoe by mutableStateOf("") @Composable override fun ComposeContent() { + androidx.compose.runtime.LaunchedEffect(Unit) { + latestMarkPrice = markPrice + latestUnrealizedPnl = unrealizedPnl + latestRoe = roe + } + androidx.compose.runtime.LaunchedEffect(positionId) { viewModel.loadPositionDetail( positionId = positionId, @@ -182,7 +208,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen .fillMaxHeight() .background(MixinAppTheme.colors.background), ) { - WalletLabel(walletName = walletName, isWeb3 = true) + WalletLabel(walletName = walletName, isWeb3 = false) Column( modifier = Modifier .verticalScroll(rememberScrollState()) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt index 1ccec10e62..ef9d17b353 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt @@ -51,7 +51,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R -import one.mixin.android.api.request.perps.OpenOrderResponse import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.booleanFromAttribute @@ -64,6 +63,7 @@ import one.mixin.android.extension.screenHeight import one.mixin.android.extension.updatePinCheck import one.mixin.android.extension.withArgs import one.mixin.android.session.Session +import one.mixin.android.ui.common.BottomSheetViewModel import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment import one.mixin.android.ui.common.PinInputBottomSheetDialogFragment import one.mixin.android.ui.common.UtxoConsolidationBottomSheetDialogFragment @@ -72,13 +72,10 @@ import one.mixin.android.ui.common.biometric.buildTransferBiometricItem import one.mixin.android.ui.home.web3.components.ActionBottom import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager -import one.mixin.android.vo.safe.TokenItem import one.mixin.android.vo.toUser -import one.mixin.android.web3.js.Web3Signer import timber.log.Timber import java.math.BigDecimal import java.util.UUID -import kotlin.math.abs @AndroidEntryPoint class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { @@ -154,8 +151,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } } - private val viewModel by viewModels() - private val bottomViewModel by viewModels() + private val bottomViewModel by viewModels() enum class Step { Pending, @@ -190,7 +186,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm .fillMaxHeight() .background(MixinAppTheme.colors.background), ) { - WalletLabel(walletName = walletName, isWeb3 = true) + WalletLabel(walletName = walletName, isWeb3 = false) Column( modifier = Modifier .verticalScroll(rememberScrollState()) From e7ea59b09091c566686be2097dbd231f1b3e31d6 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 25 Feb 2026 16:55:04 +0800 Subject: [PATCH 008/105] Update perps comfirm --- .../one.mixin.android.db.PerpsDatabase/1.json | 12 +- .../android/api/response/perps/PerpsMarket.kt | 3 + .../api/response/perps/PositionHistoryView.kt | 28 ++ .../api/response/perps/PositionView.kt | 24 -- .../home/web3/trade/MarketListBottomSheet.kt | 2 +- .../ui/home/web3/trade/OpenPositionPage.kt | 24 +- .../ui/home/web3/trade/PerpetualContent.kt | 2 +- .../ui/home/web3/trade/PerpetualViewModel.kt | 2 +- .../PerpsConfirmBottomSheetDialogFragment.kt | 240 ++++++++++++------ app/src/main/res/values-zh-rCN/strings.xml | 10 + app/src/main/res/values/strings.xml | 7 + 11 files changed, 235 insertions(+), 119 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index 6c4742499e..aecd6350ee 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "242ad9ad4f162c802672108bcbc4826d", + "identityHash": "b2bbd68ad83ee60a89e9d19ac23423e3", "entities": [ { "tableName": "perps_positions", @@ -104,7 +104,7 @@ }, { "tableName": "perps_markets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `market` TEXT NOT NULL, `symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `maker_fee` TEXT NOT NULL, `taker_fee` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `quantity_increment` TEXT NOT NULL, `price_increment` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `change` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `market` TEXT NOT NULL, `symbol` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `maker_fee` TEXT NOT NULL, `taker_fee` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `quantity_increment` TEXT NOT NULL, `price_increment` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `change` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", "fields": [ { "fieldPath": "marketId", @@ -124,6 +124,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "displaySymbol", + "columnName": "display_symbol", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "markPrice", "columnName": "mark_price", @@ -225,7 +231,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '242ad9ad4f162c802672108bcbc4826d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b2bbd68ad83ee60a89e9d19ac23423e3')" ] } } \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index 9ef449b8f1..8c443aae78 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -17,6 +17,9 @@ data class PerpsMarket( @SerializedName("symbol") @ColumnInfo(name = "symbol") val symbol: String, + @SerializedName("display_symbol") + @ColumnInfo(name = "display_symbol") + val displaySymbol: String, @SerializedName("mark_price") @ColumnInfo(name = "mark_price") val markPrice: String, diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt new file mode 100644 index 0000000000..4d5b1787b9 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt @@ -0,0 +1,28 @@ +package one.mixin.android.api.response.perps + +import com.google.gson.annotations.SerializedName + +data class PositionHistoryView( + @SerializedName("history_id") + val historyId: String, + @SerializedName("position_id") + val positionId: String, + @SerializedName("market_symbol") + val marketSymbol: String, + @SerializedName("side") + val side: String, + @SerializedName("quantity") + val quantity: String, + @SerializedName("entry_price") + val entryPrice: String, + @SerializedName("close_price") + val closePrice: String, + @SerializedName("realized_pnl") + val realizedPnl: String, + @SerializedName("leverage") + val leverage: Int, + @SerializedName("open_at") + val openAt: String, + @SerializedName("closed_at") + val closedAt: String +) \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt index fd3b6462df..ba02e3520c 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt @@ -54,27 +54,3 @@ data class PerpsPosition( val updatedAt: String? = null ) -data class PositionHistoryView( - @SerializedName("history_id") - val historyId: String, - @SerializedName("position_id") - val positionId: String, - @SerializedName("market_symbol") - val marketSymbol: String, - @SerializedName("side") - val side: String, - @SerializedName("quantity") - val quantity: String, - @SerializedName("entry_price") - val entryPrice: String, - @SerializedName("close_price") - val closePrice: String, - @SerializedName("realized_pnl") - val realizedPnl: String, - @SerializedName("leverage") - val leverage: Int, - @SerializedName("open_at") - val openAt: String, - @SerializedName("closed_at") - val closedAt: String -) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt index dd23a7daf1..543ee9020b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt @@ -124,7 +124,7 @@ private fun MarketListItem( Column { Text( - text = market.symbol, + text = market.displaySymbol, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MixinAppTheme.colors.textPrimary, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index 2926e89c57..8de63ff70b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -59,6 +59,7 @@ import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.safe.TokenItem import one.mixin.android.web3.js.Web3Signer +import timber.log.Timber import java.math.BigDecimal import kotlin.math.abs @@ -398,31 +399,30 @@ fun OpenPositionPage( if (walletId.isEmpty()) return@Button val activity = context as? androidx.fragment.app.FragmentActivity ?: return@Button + + val price = m.markPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (price == BigDecimal.ZERO) return@Button - val walletName = "Privacy Wallet" - + val orderValue = amount * BigDecimal(leverage.toDouble()) + viewModel.openPerpsOrder( assetId = token.assetId, productId = marketId, side = if (isLong) "long" else "short", - amount = amount.toPlainString(), + amount = orderValue.stripTrailingZeros().toPlainString(), leverage = leverage.toInt(), walletId = walletId, marketSymbol = marketSymbol, entryPrice = m.markPrice, onSuccess = { response -> PerpsConfirmBottomSheetDialogFragment.newInstance( - marketId = marketId, - marketSymbol = marketSymbol, + marketSymbol = m.displaySymbol, + marketIcon = m.iconUrl, isLong = isLong, - amount = amount.toPlainString(), + amount = response.payAmount ?: "", leverage = leverage.toInt(), entryPrice = m.markPrice, - liquidationPrice = calculateLiquidationPrice(m.markPrice, leverage, isLong), tokenSymbol = token.symbol, - tokenIcon = token.iconUrl ?: "", - tokenAssetId = token.assetId, - walletName = walletName, payUrl = response.payUrl ).setOnDone { onBack() @@ -672,7 +672,7 @@ private fun calculateLiquidationPrice( isLong: Boolean, ): String { val price = currentPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - if (price == BigDecimal.ZERO) return "$0" + if (price == BigDecimal.ZERO) return "0" val liquidationPercent = BigDecimal(100.0 / leverage) val liquidationPrice = if (isLong) { @@ -681,5 +681,5 @@ private fun calculateLiquidationPrice( price * (BigDecimal.ONE + liquidationPercent / BigDecimal(100)) } - return "$${String.format("%.2f", liquidationPrice)}" + return String.format("%.2f", liquidationPrice) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index 2409e96dd1..9d0e803124 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -398,7 +398,7 @@ fun MarketItem(market: PerpsMarket, onClick: () -> Unit = {}) { Column { Text( - text = market.symbol, + text = market.displaySymbol, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MixinAppTheme.colors.textPrimary, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index e0a6602de9..98cb6540a7 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -87,7 +87,7 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { - Timber.d("Market detail loaded: ${data.symbol}") + Timber.d("Market detail loaded: ${data.displaySymbol}") onSuccess(data) } else { val error = "Failed to load market detail: ${response.errorDescription}" diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt index ef9d17b353..6773b9b100 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt @@ -30,8 +30,10 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -70,8 +72,12 @@ import one.mixin.android.ui.common.UtxoConsolidationBottomSheetDialogFragment import one.mixin.android.ui.common.biometric.BiometricInfo import one.mixin.android.ui.common.biometric.buildTransferBiometricItem import one.mixin.android.ui.home.web3.components.ActionBottom +import one.mixin.android.ui.tip.wc.compose.ItemWalletContent +import one.mixin.android.ui.wallet.ItemUserContent +import one.mixin.android.ui.wallet.SwapTransferBottomSheetDialogFragment import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager +import one.mixin.android.vo.User import one.mixin.android.vo.toUser import timber.log.Timber import java.math.BigDecimal @@ -84,43 +90,32 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm const val TAG = "PerpsConfirmBottomSheetDialogFragment" private const val ARGS_MARKET_ID = "args_market_id" private const val ARGS_MARKET_SYMBOL = "args_market_symbol" + private const val ARGS_MARKET_ICON = "args_market_icon" private const val ARGS_IS_LONG = "args_is_long" private const val ARGS_AMOUNT = "args_amount" private const val ARGS_LEVERAGE = "args_leverage" private const val ARGS_ENTRY_PRICE = "args_entry_price" - private const val ARGS_LIQUIDATION_PRICE = "args_liquidation_price" private const val ARGS_TOKEN_SYMBOL = "args_token_symbol" - private const val ARGS_TOKEN_ICON = "args_token_icon" - private const val ARGS_TOKEN_ASSET_ID = "args_token_asset_id" - private const val ARGS_WALLET_NAME = "args_wallet_name" private const val ARGS_PAY_URL = "args_pay_url" fun newInstance( - marketId: String, marketSymbol: String, + marketIcon: String, isLong: Boolean, amount: String, leverage: Int, entryPrice: String, - liquidationPrice: String, tokenSymbol: String, - tokenIcon: String, - tokenAssetId: String, - walletName: String, - payUrl: String? + payUrl: String?, ): PerpsConfirmBottomSheetDialogFragment { return PerpsConfirmBottomSheetDialogFragment().withArgs { - putString(ARGS_MARKET_ID, marketId) putString(ARGS_MARKET_SYMBOL, marketSymbol) + putString(ARGS_MARKET_ICON, marketIcon) putBoolean(ARGS_IS_LONG, isLong) putString(ARGS_AMOUNT, amount) putInt(ARGS_LEVERAGE, leverage) putString(ARGS_ENTRY_PRICE, entryPrice) - putString(ARGS_LIQUIDATION_PRICE, liquidationPrice) putString(ARGS_TOKEN_SYMBOL, tokenSymbol) - putString(ARGS_TOKEN_ICON, tokenIcon) - putString(ARGS_TOKEN_ASSET_ID, tokenAssetId) - putString(ARGS_WALLET_NAME, walletName) putString(ARGS_PAY_URL, payUrl) } } @@ -160,24 +155,60 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Error, } - private val marketId by lazy { requireNotNull(requireArguments().getString(ARGS_MARKET_ID)) } private val marketSymbol by lazy { requireNotNull(requireArguments().getString(ARGS_MARKET_SYMBOL)) } + private val marketIcon by lazy { requireNotNull(requireArguments().getString(ARGS_MARKET_ICON)) } private val isLong by lazy { requireArguments().getBoolean(ARGS_IS_LONG) } private val amount by lazy { requireNotNull(requireArguments().getString(ARGS_AMOUNT)) } private val leverage by lazy { requireArguments().getInt(ARGS_LEVERAGE) } private val entryPrice by lazy { requireNotNull(requireArguments().getString(ARGS_ENTRY_PRICE)) } - private val liquidationPrice by lazy { requireNotNull(requireArguments().getString(ARGS_LIQUIDATION_PRICE)) } private val tokenSymbol by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_SYMBOL)) } - private val tokenIcon by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_ICON)) } - private val tokenAssetId by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_ASSET_ID)) } - private val walletName by lazy { requireNotNull(requireArguments().getString(ARGS_WALLET_NAME)) } + private val payUrl by lazy { requireArguments().getString(ARGS_PAY_URL) } + private val liquidationPrice by lazy { + try { + val price = entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + Timber.d("LiquidationPrice - entryPrice: $entryPrice, leverage: $leverage, isLong: $isLong, price: $price") + + if (price == BigDecimal.ZERO) { + "0" + } else { + val liquidationPercent = BigDecimal(100.0 / leverage) + val liquidation = if (isLong) { + price * (BigDecimal.ONE - liquidationPercent / BigDecimal(100)) + } else { + price * (BigDecimal.ONE + liquidationPercent / BigDecimal(100)) + } + val result = String.format("%.2f", liquidation) + Timber.d("LiquidationPrice - liquidationPercent: $liquidationPercent, liquidation: $liquidation, result: $result") + result + } + } catch (e: Exception) { + Timber.e(e, "Failed to calculate liquidation price") + "0" + } + } + private var step by mutableStateOf(Step.Pending) private var errorInfo: String? by mutableStateOf(null) + private var receiver: User? by mutableStateOf(null) @Composable override fun ComposeContent() { + LaunchedEffect(payUrl) { + payUrl?.let { url -> + try { + val uri = Uri.parse(url) + val receiverId = uri.lastPathSegment + receiverId?.let { userId -> + receiver = bottomViewModel.refreshUser(userId) + } + } catch (e: Exception) { + Timber.e(e, "Failed to parse receiver from payUrl") + } + } + } + MixinAppTheme { Column( modifier = Modifier @@ -186,7 +217,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm .fillMaxHeight() .background(MixinAppTheme.colors.background), ) { - WalletLabel(walletName = walletName, isWeb3 = false) + WalletLabel(walletName = getString(R.string.Privacy_Wallet), isWeb3 = false) Column( modifier = Modifier .verticalScroll(rememberScrollState()) @@ -201,6 +232,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm color = MixinAppTheme.colors.accent, ) } + Step.Error -> { androidx.compose.material.Icon( modifier = Modifier.size(70.dp), @@ -209,6 +241,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm tint = Color.Unspecified, ) } + Step.Done -> { androidx.compose.material.Icon( modifier = Modifier.size(70.dp), @@ -217,9 +250,10 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm tint = Color.Unspecified, ) } + else -> { CoilImage( - model = tokenIcon, + model = marketIcon, placeholder = R.drawable.ic_avatar_place_holder, modifier = Modifier .size(70.dp) @@ -231,8 +265,8 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Text( text = stringResource( id = when (step) { - Step.Pending -> R.string.Perpetual - Step.Done -> R.string.web3_sending_success + Step.Pending -> R.string.Confirm_Open_Position + Step.Done -> R.string.Open_Position_Success Step.Error -> R.string.swap_failed Step.Sending -> R.string.Sending } @@ -246,7 +280,14 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Box(modifier = Modifier.height(8.dp)) Text( modifier = Modifier.padding(horizontal = 24.dp), - text = errorInfo ?: "$marketSymbol - USD", + text = errorInfo ?: stringResource( + id = + when (step) { + Step.Done -> R.string.swap_message_success + Step.Error -> R.string.Data_error + else -> R.string.swap_inner_desc + }, + ), textAlign = TextAlign.Center, style = TextStyle( color = if (errorInfo != null) MixinAppTheme.colors.tipError else MixinAppTheme.colors.textMinor, @@ -266,12 +307,41 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm ) Box(modifier = Modifier.height(20.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = stringResource(R.string.Perpetual), + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Row { + CoilImage( + model = marketIcon, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = marketSymbol, + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.W400 + ) + } + } + Box(modifier = Modifier.height(20.dp)) + PerpsInfoItem( title = stringResource(R.string.Perpetual_Direction).uppercase(), value = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${leverage}x" ) - Box(modifier = Modifier.height(20.dp)) - + Box(modifier = Modifier.height(6.dp)) ProfitLossInfo( amount = amount, leverage = leverage, @@ -280,34 +350,55 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Box(modifier = Modifier.height(20.dp)) PerpsInfoItem( - title = stringResource(R.string.Amount).uppercase() + " (Isolated)", + title = stringResource(R.string.Amount).uppercase() + " (${stringResource(R.string.Isolated)})", value = "$amount $tokenSymbol" ) Box(modifier = Modifier.height(20.dp)) PerpsInfoItem( - title = "Entry Price".uppercase(), + title = stringResource(R.string.Entry_Price).uppercase(), value = "$$entryPrice" ) Box(modifier = Modifier.height(20.dp)) + val lossPercent = remember(leverage) { + val percent = String.format("%.2f", 100.0 / leverage) + Timber.d("LossPercent - leverage: $leverage, lossPercent: $percent") + percent + } + + val lossSubValue = if (isLong) { + val text = stringResource( + R.string.Price_Down_Loss, + lossPercent, + amount, + tokenSymbol + ) + Timber.d("LossSubValue (Long) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") + text + } else { + val text = stringResource( + R.string.Price_Up_Loss, + lossPercent, + amount, + tokenSymbol + ) + Timber.d("LossSubValue (Short) - lossPercent: $lossPercent, amount: $amount, tokenSymbol: $tokenSymbol, text: $text") + text + } + PerpsInfoItem( - title = stringResource(R.string.Liquidation_Price).uppercase(), - value = "$$liquidationPrice", - subValue = calculateLiquidationPercentage(entryPrice, liquidationPrice, isLong) + title = stringResource(R.string.Estimated_Liquidation_Price).uppercase(), + value = liquidationPrice, + subValue = lossSubValue ) + Box(modifier = Modifier.height(20.dp)) - PerpsInfoItem( - title = stringResource(R.string.Receiver).uppercase(), - value = "Mixin Futures (7000105155)" - ) + ItemUserContent(title = stringResource(id = R.string.Receiver).uppercase(), receiver, null) Box(modifier = Modifier.height(20.dp)) - PerpsInfoItem( - title = stringResource(R.string.Sender).uppercase(), - value = walletName - ) + ItemWalletContent(title = stringResource(id = R.string.Sender).uppercase(), fontSize = 16.sp) Box(modifier = Modifier.height(16.dp)) } @@ -337,6 +428,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } } } + Step.Error -> { ActionBottom( modifier = Modifier.align(Alignment.BottomCenter), @@ -346,6 +438,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm confirmAction = { showPin() }, ) } + Step.Pending -> { ActionBottom( modifier = Modifier.align(Alignment.BottomCenter), @@ -355,6 +448,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm confirmAction = { showPin() }, ) } + Step.Sending -> {} } } @@ -367,7 +461,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private fun PerpsInfoItem( title: String, value: String, - subValue: String? = null + subValue: String? = null, ) { Column( modifier = Modifier @@ -402,47 +496,39 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private fun ProfitLossInfo( amount: String, leverage: Int, - isLong: Boolean + isLong: Boolean, ) { val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO val profitPercent = 1.0 * leverage val profitAmount = amountValue * BigDecimal(profitPercent / 100) - val lossPercent = 100.0 / leverage - Column( + Timber.d("ProfitLossInfo - amount: $amount, amountValue: $amountValue, leverage: $leverage, isLong: $isLong, profitPercent: $profitPercent, profitAmount: $profitAmount") + + Text( modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp), - ) { - Text( - text = "价格${if (isLong) "上涨" else "下跌"} 1% → 盈利 ${String.format("%.1f", profitPercent)}% (+$${String.format("%.2f", profitAmount)})", - color = MixinAppTheme.colors.walletGreen, - fontSize = 14.sp, - ) - Box(modifier = Modifier.height(4.dp)) - Text( - text = "价格${if (isLong) "下跌" else "上涨"} ${String.format("%.2f", lossPercent)}% → 亏损 -$${amount}", - color = MixinAppTheme.colors.walletRed, - fontSize = 14.sp, - ) - } - } - - private fun calculateLiquidationPercentage(entryPrice: String, liquidationPrice: String, isLong: Boolean): String { - return try { - val entry = BigDecimal(entryPrice) - val liquidation = BigDecimal(liquidationPrice) - val diff = if (isLong) { - (entry - liquidation).divide(entry, 4, java.math.RoundingMode.HALF_UP) * BigDecimal(100) + text = if (isLong) { + stringResource( + R.string.Price_Up_Profit, + "1", + String.format("%.1f", profitPercent), + String.format("%.2f", profitAmount) + ) } else { - (liquidation - entry).divide(entry, 4, java.math.RoundingMode.HALF_UP) * BigDecimal(100) - } - "价格${if (isLong) "下跌" else "上涨"} ${String.format("%.2f", diff)}% → 亏损 -$${amount}" - } catch (e: Exception) { - "" - } + stringResource( + R.string.Price_Down_Profit, + "1", + String.format("%.1f", profitPercent), + String.format("%.2f", profitAmount) + ) + }, + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + ) } + override fun getBottomSheetHeight(view: View): Int { return requireContext().screenHeight() - view.getSafeAreaInsetsTop() } @@ -481,7 +567,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private fun doAfterPinComplete(pin: String) = lifecycleScope.launch(Dispatchers.IO) { try { step = Step.Sending - + if (payUrl != null) { handlePayment(payUrl!!, pin) } else { @@ -512,16 +598,16 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private suspend fun handlePayment(payUrl: String, pin: String) { try { val uri = Uri.parse(payUrl) - + val assetId = requireNotNull(uri.getQueryParameter("asset")) val payAmount = requireNotNull(uri.getQueryParameter("amount")) val receiverId = requireNotNull(uri.lastPathSegment) val memo = uri.getQueryParameter("memo") val traceId = uri.getQueryParameter("trace") ?: UUID.randomUUID().toString() - + val consolidationAmount = bottomViewModel.checkUtxoSufficiency(assetId, payAmount) val token = bottomViewModel.findAssetItemById(assetId) - + if (consolidationAmount != null && token != null) { UtxoConsolidationBottomSheetDialogFragment.newInstance( buildTransferBiometricItem( @@ -540,7 +626,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm step = Step.Error return } - + val paymentResponse = bottomViewModel.kernelTransaction( assetId, listOf(receiverId), @@ -550,7 +636,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm traceId, memo ) - + if (paymentResponse.isSuccess) { defaultSharedPreferences.putLong( Constants.BIOMETRIC_PIN_CHECK, diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e0debd00a7..321fbf3193 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2245,6 +2245,10 @@ 举例说明 交易对 开仓方向 + 价格上涨 %1$s%% → 盈利 %2$s%% (+%3$s) + 价格下跌 %1$s%% → 盈利 %2$s%% (+%3$s) + 价格下跌 %1$s%% → 亏损 -%2$s %3$s + 价格上涨 %1$s%% → 亏损 -%2$s %3$s 杠杆倍数 投入资金 仓位价值 @@ -2277,4 +2281,10 @@ 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 做空 + 开仓价格 + 逐仓 + Mixin Futures + 确认开仓 + 开仓成功 + 预估强平价格 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 779117dbc8..f292e57ba9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2303,6 +2303,10 @@ Example Trading Pair Direction + Price up %1$s%% → Profit %2$s%% (+%3$s) + Price down %1$s%% → Profit %2$s%% (+%3$s) + Price down %1$s%% → Loss -%2$s %3$s + Price up %1$s%% → Loss -%2$s %3$s Leverage Investment Position Value @@ -2344,4 +2348,7 @@ 24H VOLUME Open Interest Funding Rate + Confirm Open Position + Open Position Success + Estimated Liquidation Price From ef66c18f1dddee07bbf84df5259e9cdc6344e067 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 26 Feb 2026 12:03:35 +0800 Subject: [PATCH 009/105] Update open position page --- .../one.mixin.android.db.PerpsDatabase/1.json | 12 +- .../android/api/response/perps/PerpsMarket.kt | 5 +- .../ui/home/web3/trade/OpenPositionPage.kt | 587 +++++++++--------- 3 files changed, 305 insertions(+), 299 deletions(-) diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index aecd6350ee..d5148bc3cf 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "b2bbd68ad83ee60a89e9d19ac23423e3", + "identityHash": "8e87c397605ae667230ba0aa93d2d3bd", "entities": [ { "tableName": "perps_positions", @@ -104,7 +104,7 @@ }, { "tableName": "perps_markets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `market` TEXT NOT NULL, `symbol` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `maker_fee` TEXT NOT NULL, `taker_fee` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `quantity_increment` TEXT NOT NULL, `price_increment` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `change` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `market` TEXT NOT NULL, `symbol` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `maker_fee` TEXT NOT NULL, `taker_fee` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `quantity_increment` TEXT NOT NULL, `price_increment` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `change` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", "fields": [ { "fieldPath": "marketId", @@ -130,6 +130,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "tokenSymbol", + "columnName": "token_symbol", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "markPrice", "columnName": "mark_price", @@ -231,7 +237,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b2bbd68ad83ee60a89e9d19ac23423e3')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8e87c397605ae667230ba0aa93d2d3bd')" ] } } \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index 8c443aae78..d614887b50 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -20,6 +20,9 @@ data class PerpsMarket( @SerializedName("display_symbol") @ColumnInfo(name = "display_symbol") val displaySymbol: String, + @SerializedName("token_symbol") + @ColumnInfo(name = "token_symbol") + val tokenSymbol: String, @SerializedName("mark_price") @ColumnInfo(name = "mark_price") val markPrice: String, @@ -64,5 +67,5 @@ data class PerpsMarket( val change: String, @SerializedName("updated_at") @ColumnInfo(name = "updated_at") - val updatedAt: String + val updatedAt: String, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index 8de63ff70b..60a5057ecf 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -28,8 +28,6 @@ import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Slider import androidx.compose.material.SliderDefaults import androidx.compose.material.Text -import androidx.compose.material.TextField -import androidx.compose.material.TextFieldDefaults import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -46,20 +44,23 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign 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 kotlinx.coroutines.launch import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.numberFormat8 import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.safe.TokenItem -import one.mixin.android.web3.js.Web3Signer -import timber.log.Timber import java.math.BigDecimal import kotlin.math.abs @@ -130,156 +131,119 @@ fun OpenPositionPage( } } ) { - PageScaffold( - title = "Open Position", - verticalScrollable = false, - pop = onBack - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) + MixinAppTheme { + PageScaffold( + title = stringResource(R.string.Open_Position), + verticalScrollable = false, + pop = onBack ) { - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - market?.let { m -> - CoilImage( - model = m.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - text = "${if (isLong) "Long" else "Short"} $marketSymbol", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = MixinAppTheme.colors.textPrimary - ) - Text( - text = "$${m.markPrice}", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textAssist - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - Column( modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) - .padding(16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) ) { - Text( - text = "Amount", - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary - ) - - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - TextField( - value = usdtAmount, - onValueChange = { usdtAmount = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("0.00") }, - colors = TextFieldDefaults.textFieldColors( - backgroundColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = MixinAppTheme.colors.textPrimary + market?.let { m -> + CoilImage( + model = m.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${m.tokenSymbol}", + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = "${stringResource(R.string.Current_price, "${m.markPrice} USD")} ", + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.Amount), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary ) - Row( - modifier = Modifier.clickable { + Spacer(modifier = Modifier.height(8.dp)) + + InputContent( + token = selectedToken?.toSwapToken(), + text = usdtAmount, + selectClick = { coroutineScope.launch { tokenBottomSheetState.show() } }, + onInputChanged = { usdtAmount = it } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - selectedToken?.let { token -> - CoilImage( - model = token.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier - .size(24.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(4.dp)) - } - Text( - text = selectedToken?.symbol ?: "USDT", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary - ) - Spacer(modifier = Modifier.width(4.dp)) Icon( - painter = painterResource(R.drawable.ic_arrow_down_info), + painter = painterResource(id = R.drawable.ic_web3_wallet), contentDescription = null, tint = MixinAppTheme.colors.textAssist, modifier = Modifier.size(16.dp) ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = selectedToken?.balance?.numberFormat8() ?: "0", + style = TextStyle( + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + textAlign = TextAlign.Start, + ), + modifier = Modifier.clickable { + usdtAmount = selectedToken?.balance ?: "0" + } + ) } } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Spacer(modifier = Modifier.height(2.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) ) { - Text( - text = "Balance: ${selectedToken?.balance ?: "0"} ${selectedToken?.symbol ?: ""}", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) + Text( - text = "MAX", - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.accent, - modifier = Modifier.clickable { - usdtAmount = selectedToken?.balance ?: "0" - } + text = stringResource(R.string.Leverage), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - - Text( - text = "Leverage", - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary - ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) Text( modifier = Modifier.clickable { @@ -293,175 +257,176 @@ fun OpenPositionPage( Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - leverageOptions.forEach { lev -> - Box( - modifier = Modifier - .weight(1f) - .height(32.dp) - .clip(RoundedCornerShape(4.dp)) - .border( - width = 1.dp, - color = MixinAppTheme.colors.textAssist, - shape = RoundedCornerShape(4.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + leverageOptions.forEach { lev -> + val isSelected = leverage.toInt() == lev + Box( + modifier = Modifier + .weight(1f) + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent) + .border( + width = 1.dp, + color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textAssist, + shape = RoundedCornerShape(16.dp) + ) + .clickable { leverage = lev.toFloat() }, + contentAlignment = Alignment.Center + ) { + Text( + text = "${lev}x", + fontSize = 12.sp, + color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textPrimary ) - .clickable { leverage = lev.toFloat() }, - contentAlignment = Alignment.Center - ) { - Text( - text = "${lev}x", - fontSize = 12.sp, - color = MixinAppTheme.colors.textPrimary - ) + } } } - } - } + Spacer(modifier = Modifier.height(12.dp)) - Spacer(modifier = Modifier.height(16.dp)) + val profitInfo = calculateProfitInfo( + amount = usdtAmount, + leverage = leverage, + isLong = isLong, + priceChangePercent = 1.0 + ) - val profitInfo = calculateProfitInfo( - amount = usdtAmount, - leverage = leverage, - isLong = isLong, - priceChangePercent = 1.0 - ) + Text( + text = profitInfo, + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.padding(horizontal = 4.dp) + ) - Text( - text = profitInfo, - fontSize = 13.sp, - color = MixinAppTheme.colors.textAssist, - modifier = Modifier.padding(horizontal = 4.dp) - ) + } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text( - text = "Order Value", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = calculateOrderValue(usdtAmount, leverage), - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary - ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = stringResource(R.string.Order_Value), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${calculateOrderValue(usdtAmount, leverage, market?.markPrice ?: "0")} ${market?.tokenSymbol}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = stringResource(R.string.Liquidation_Price), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = calculateLiquidationPrice( + market?.markPrice ?: "0", + leverage, + isLong + ), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } } - Column(horizontalAlignment = Alignment.End) { - Text( - text = "Liquidation Price", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(16.dp)) + + Button( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .height(48.dp), + onClick = { + val token = selectedToken ?: return@Button + val amount = usdtAmount.toBigDecimalOrNull() ?: return@Button + + if (amount <= BigDecimal.ZERO) return@Button + + val m = market ?: return@Button + val walletId = Session.getAccountId() ?: "" // Privacy Wallet + if (walletId.isEmpty()) return@Button + + val activity = context as? FragmentActivity ?: return@Button + + val price = m.markPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (price == BigDecimal.ZERO) return@Button + + val orderValue = amount * BigDecimal(leverage.toDouble()) + + viewModel.openPerpsOrder( + assetId = token.assetId, + productId = marketId, + side = if (isLong) "long" else "short", + amount = orderValue.stripTrailingZeros().toPlainString(), + leverage = leverage.toInt(), + walletId = walletId, + marketSymbol = marketSymbol, + entryPrice = m.markPrice, + onSuccess = { response -> + PerpsConfirmBottomSheetDialogFragment.newInstance( + marketSymbol = m.displaySymbol, + marketIcon = m.iconUrl, + isLong = isLong, + amount = response.payAmount ?: "", + leverage = leverage.toInt(), + entryPrice = m.markPrice, + tokenSymbol = token.symbol, + payUrl = response.payUrl + ).setOnDone { + onBack() + }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + }, + onError = { error -> + // TODO: Show error toast or dialog + } + ) + }, + enabled = usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { + MixinAppTheme.colors.accent + } else { + MixinAppTheme.colors.backgroundGrayLight + } + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp ) - Spacer(modifier = Modifier.height(4.dp)) + ) { Text( - text = calculateLiquidationPrice( - market?.markPrice ?: "0", - leverage, - isLong - ), - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary + text = stringResource(R.string.Review), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { + Color.White + } else { + MixinAppTheme.colors.textAssist + } ) } - } - - Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.height(16.dp)) - Button( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth() - .height(48.dp), - onClick = { - val token = selectedToken ?: return@Button - val amount = usdtAmount.toBigDecimalOrNull() ?: return@Button - - if (amount <= BigDecimal.ZERO) return@Button - - val m = market ?: return@Button - val walletId = Session.getAccountId() ?: "" // Privacy Wallet - if (walletId.isEmpty()) return@Button - - val activity = context as? androidx.fragment.app.FragmentActivity ?: return@Button - - val price = m.markPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - if (price == BigDecimal.ZERO) return@Button - - val orderValue = amount * BigDecimal(leverage.toDouble()) - - viewModel.openPerpsOrder( - assetId = token.assetId, - productId = marketId, - side = if (isLong) "long" else "short", - amount = orderValue.stripTrailingZeros().toPlainString(), - leverage = leverage.toInt(), - walletId = walletId, - marketSymbol = marketSymbol, - entryPrice = m.markPrice, - onSuccess = { response -> - PerpsConfirmBottomSheetDialogFragment.newInstance( - marketSymbol = m.displaySymbol, - marketIcon = m.iconUrl, - isLong = isLong, - amount = response.payAmount ?: "", - leverage = leverage.toInt(), - entryPrice = m.markPrice, - tokenSymbol = token.symbol, - payUrl = response.payUrl - ).setOnDone { - onBack() - }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) - }, - onError = { error -> - // TODO: Show error toast or dialog - } - ) - }, - enabled = usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO, - colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { - if (isLong) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed - } else { - MixinAppTheme.colors.backgroundGrayLight - } - ), - shape = RoundedCornerShape(32.dp), - elevation = ButtonDefaults.elevation( - pressedElevation = 0.dp, - defaultElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp - ) - ) { - Text( - text = "Review", - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { - Color.White - } else { - MixinAppTheme.colors.textAssist - } - ) + Spacer(modifier = Modifier.height(24.dp)) } - - Spacer(modifier = Modifier.height(24.dp)) } } } @@ -479,7 +444,7 @@ private fun TokenSelectionBottomSheet( .padding(16.dp) ) { Text( - text = "Select Token", + text = stringResource(R.string.Select_Token), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MixinAppTheme.colors.textPrimary @@ -557,7 +522,7 @@ private fun LeverageBottomSheet( .padding(16.dp) ) { Text( - text = "Select Leverage", + text = stringResource(R.string.Select_Leverage), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MixinAppTheme.colors.textPrimary @@ -618,7 +583,7 @@ private fun LeverageBottomSheet( contentAlignment = Alignment.Center ) { Text( - text = "Confirm", + text = stringResource(R.string.Confirm), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White @@ -631,7 +596,7 @@ private fun LeverageBottomSheet( private fun generateLeverageOptions(maxLeverage: Int): List { val options = mutableListOf() - val baseOptions = listOf(1, 2, 5, 10, 25, 50, 100) + val baseOptions = listOf(1, 2, 5, 10, 20, 100) baseOptions.forEach { option -> if (option <= maxLeverage) { @@ -642,6 +607,7 @@ private fun generateLeverageOptions(maxLeverage: Int): List { return options.take(7) } +@Composable private fun calculateProfitInfo( amount: String, leverage: Float, @@ -649,37 +615,68 @@ private fun calculateProfitInfo( priceChangePercent: Double, ): String { val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO - if (amountValue == BigDecimal.ZERO) return "Price up 1% → Profit 0% (+$0.00)" + if (amountValue == BigDecimal.ZERO) { + return if (isLong) { + stringResource(R.string.Price_Up_Profit, "1", "0.0", "0.00") + } else { + stringResource(R.string.Price_Down_Profit, "1", "0.0", "0.00") + } + } val profitPercent = priceChangePercent * leverage val profitAmount = amountValue * BigDecimal(profitPercent / 100) - val direction = if (isLong) "up" else "down" - val sign = if (profitAmount >= BigDecimal.ZERO) "+" else "" - - return "Price $direction ${String.format("%.0f", abs(priceChangePercent))}% → Profit ${sign}${String.format("%.1f", profitPercent)}% (${sign}$${String.format("%.2f", profitAmount)})" + return if (isLong) { + stringResource( + R.string.Price_Up_Profit, + String.format("%.0f", abs(priceChangePercent)), + String.format("%.1f", profitPercent), + String.format("%.2f", profitAmount) + ) + } else { + stringResource( + R.string.Price_Down_Profit, + String.format("%.0f", abs(priceChangePercent)), + String.format("%.1f", profitPercent), + String.format("%.2f", profitAmount) + ) + } } -private fun calculateOrderValue(amount: String, leverage: Float): String { +private fun calculateOrderValue(amount: String, leverage: Float, price: String): String { val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO - val orderValue = amountValue * BigDecimal(leverage.toDouble()) - return "$${String.format("%.2f", orderValue)}" -} + val priceValue = price.toBigDecimalOrNull() ?: BigDecimal.ZERO + + + if (priceValue == BigDecimal.ZERO) { + return "0" + } + + val orderValue = (amountValue * BigDecimal(leverage.toDouble())).divide(priceValue, 8, java.math.RoundingMode.HALF_UP) + val result = orderValue.stripTrailingZeros().toPlainString() + return result +} private fun calculateLiquidationPrice( currentPrice: String, leverage: Float, isLong: Boolean, ): String { val price = currentPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - if (price == BigDecimal.ZERO) return "0" + + + if (price == BigDecimal.ZERO) { + return "$0" + } val liquidationPercent = BigDecimal(100.0 / leverage) + val liquidationRatio = liquidationPercent.divide(BigDecimal(100), 8, java.math.RoundingMode.HALF_UP) val liquidationPrice = if (isLong) { - price * (BigDecimal.ONE - liquidationPercent / BigDecimal(100)) + price * (BigDecimal.ONE - liquidationRatio) } else { - price * (BigDecimal.ONE + liquidationPercent / BigDecimal(100)) + price * (BigDecimal.ONE + liquidationRatio) } - return String.format("%.2f", liquidationPrice) + val result = "$${String.format("%.2f", liquidationPrice)}" + return result } From 943b7adabae7eecd84ba903f9ef0259991d1d0be Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 26 Feb 2026 14:18:27 +0800 Subject: [PATCH 010/105] Update close position --- .../one.mixin.android.db.PerpsDatabase/1.json | 23 +- .../api/response/perps/PositionView.kt | 9 +- .../ui/home/web3/trade/MarketDetailPage.kt | 19 +- .../ui/home/web3/trade/PerpetualViewModel.kt | 5 +- .../PerpsCloseBottomSheetDialogFragment.kt | 254 ++++++++++++------ app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 7 files changed, 206 insertions(+), 110 deletions(-) diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index d5148bc3cf..68511a4055 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "8e87c397605ae667230ba0aa93d2d3bd", + "identityHash": "1ca362aa376fdb9dc505bf1d49fae4b9", "entities": [ { "tableName": "perps_positions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `market_symbol` TEXT, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, PRIMARY KEY(`position_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `settle_asset_id` TEXT NOT NULL, `bot_id` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, PRIMARY KEY(`position_id`))", "fields": [ { "fieldPath": "positionId", @@ -20,11 +20,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "marketSymbol", - "columnName": "market_symbol", - "affinity": "TEXT" - }, { "fieldPath": "side", "columnName": "side", @@ -37,6 +32,18 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "settleAssetId", + "columnName": "settle_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "botId", + "columnName": "bot_id", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "entryPrice", "columnName": "entry_price", @@ -237,7 +244,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8e87c397605ae667230ba0aa93d2d3bd')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1ca362aa376fdb9dc505bf1d49fae4b9')" ] } } \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt index ba02e3520c..eccc54027b 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt @@ -14,15 +14,18 @@ data class PerpsPosition( @SerializedName("product_id") @ColumnInfo(name = "product_id") val productId: String, - @SerializedName("market_symbol") - @ColumnInfo(name = "market_symbol") - val marketSymbol: String? = null, @SerializedName("side") @ColumnInfo(name = "side") val side: String, @SerializedName("quantity") @ColumnInfo(name = "quantity") val quantity: String, + @SerializedName("settle_asset_id") + @ColumnInfo(name = "settle_asset_id") + val settleAssetId: String, + @SerializedName("bot_id") + @ColumnInfo(name = "bot_id") + val botId: String, @SerializedName("entry_price") @ColumnInfo(name = "entry_price") val entryPrice: String, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt index 75fa22a776..e794fed296 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -1,7 +1,6 @@ package one.mixin.android.ui.home.web3.trade import PageScaffold -import android.graphics.drawable.Icon import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -39,6 +38,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -160,7 +160,6 @@ fun MarketDetailPage( PerpsCloseBottomSheetDialogFragment.newInstance( position = position, - walletName = "Privacy Wallet" ).setOnDone { currentPosition = null }.show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) @@ -177,7 +176,7 @@ fun MarketDetailPage( ) ) { Text( - text = "Close Position", + text = stringResource(R.string.Close_Position), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White @@ -212,7 +211,7 @@ fun MarketDetailPage( ) ) { Text( - text = "Long", + text = stringResource(R.string.Long), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White @@ -243,7 +242,7 @@ fun MarketDetailPage( ) ) { Text( - text = "Short", + text = stringResource(R.string.Short), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White @@ -280,13 +279,13 @@ private fun MarketInfoCard( Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "How perps works?", + text = stringResource(R.string.How_Perps_Works), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary ) Text( - text = "Learn how to trade perps", + text = stringResource(R.string.Learn_How_To_Trade_Perps), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) @@ -304,7 +303,7 @@ private fun MarketInfoCard( .padding(16.dp) ) { Text( - text = "24H VOLUME", + text = stringResource(R.string.Volume_24H), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) @@ -321,7 +320,7 @@ private fun MarketInfoCard( Column { Text( - text = "Open Interest", + text = stringResource(R.string.Open_Interest), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) @@ -337,7 +336,7 @@ private fun MarketInfoCard( Spacer(modifier = Modifier.height(12.dp)) Column { Text( - text = "Funding Rate", + text = stringResource(R.string.Funding_Rate), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index 98cb6540a7..16bdb9a1f7 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -184,11 +184,11 @@ class PerpetualViewModel @Inject constructor( val position = PerpsPosition( positionId = data.orderId, - walletId = walletId, productId = productId, - marketSymbol = marketSymbol, side = side, quantity = amount, + settleAssetId = assetId, + botId = "", entryPrice = entryPrice, margin = amount, leverage = leverage, @@ -196,6 +196,7 @@ class PerpetualViewModel @Inject constructor( markPrice = entryPrice, unrealizedPnl = "0", roe = "0", + walletId = walletId, createdAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()), updatedAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()) ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt index 11505cf032..48201c996b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt @@ -3,7 +3,6 @@ package one.mixin.android.ui.home.web3.trade 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 @@ -19,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -28,6 +28,7 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -35,6 +36,7 @@ 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.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -48,23 +50,23 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.booleanFromAttribute import one.mixin.android.extension.composeDp -import one.mixin.android.extension.defaultSharedPreferences -import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.isNightMode -import one.mixin.android.extension.putLong import one.mixin.android.extension.screenHeight -import one.mixin.android.extension.updatePinCheck import one.mixin.android.extension.withArgs +import one.mixin.android.ui.common.BottomSheetViewModel import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment -import one.mixin.android.ui.common.PinInputBottomSheetDialogFragment -import one.mixin.android.ui.common.biometric.BiometricInfo import one.mixin.android.ui.home.web3.components.ActionBottom +import one.mixin.android.ui.tip.wc.compose.ItemWalletContent +import one.mixin.android.ui.wallet.ItemUserContent import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager +import one.mixin.android.vo.User +import one.mixin.android.vo.safe.TokenItem import timber.log.Timber import java.math.BigDecimal @@ -74,9 +76,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen companion object { const val TAG = "PerpsCloseBottomSheetDialogFragment" private const val ARGS_POSITION_ID = "args_position_id" - private const val ARGS_MARKET_SYMBOL = "args_market_symbol" private const val ARGS_SIDE = "args_side" - private const val ARGS_QUANTITY = "args_quantity" + private const val ARGS_MARGIN = "args_margin" private const val ARGS_LEVERAGE = "args_leverage" private const val ARGS_ENTRY_PRICE = "args_entry_price" private const val ARGS_MARK_PRICE = "args_mark_price" @@ -86,19 +87,16 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen fun newInstance( position: PerpsPosition, - walletName: String ): PerpsCloseBottomSheetDialogFragment { return PerpsCloseBottomSheetDialogFragment().withArgs { putString(ARGS_POSITION_ID, position.positionId) - putString(ARGS_MARKET_SYMBOL, position.marketSymbol ?: "") putString(ARGS_SIDE, position.side) - putString(ARGS_QUANTITY, position.quantity) + putString(ARGS_MARGIN, position.margin) putInt(ARGS_LEVERAGE, position.leverage) putString(ARGS_ENTRY_PRICE, position.entryPrice) putString(ARGS_MARK_PRICE, position.markPrice) putString(ARGS_UNREALIZED_PNL, position.unrealizedPnl) putString(ARGS_ROE, position.roe) - putString(ARGS_WALLET_NAME, walletName) } } } @@ -129,6 +127,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } private val viewModel by viewModels() + private val bottomViewModel by viewModels() enum class Step { Pending, @@ -137,61 +136,71 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Error, } - private val positionId by lazy { + private val positionId by lazy { requireNotNull(requireArguments().getString(ARGS_POSITION_ID)) { "positionId is null" } } - private val marketSymbol by lazy { - requireArguments().getString(ARGS_MARKET_SYMBOL) ?: "Unknown" - } - private val side by lazy { - requireNotNull(requireArguments().getString(ARGS_SIDE)) { "side is null" } - } - private val quantity by lazy { - requireNotNull(requireArguments().getString(ARGS_QUANTITY)) { "quantity is null" } - } - private val leverage by lazy { - requireArguments().getInt(ARGS_LEVERAGE) - } - private val entryPrice by lazy { - requireNotNull(requireArguments().getString(ARGS_ENTRY_PRICE)) { "entryPrice is null" } + + private val margin by lazy { + requireNotNull(requireArguments().getString(ARGS_MARGIN)) { "margin is null" } } - private val markPrice by lazy { + private val markPrice by lazy { requireNotNull(requireArguments().getString(ARGS_MARK_PRICE)) { "markPrice is null" } } - private val unrealizedPnl by lazy { + private val unrealizedPnl by lazy { requireNotNull(requireArguments().getString(ARGS_UNREALIZED_PNL)) { "unrealizedPnl is null" } } - private val roe by lazy { + private val roe by lazy { requireNotNull(requireArguments().getString(ARGS_ROE)) { "roe is null" } } - private val walletName by lazy { - requireNotNull(requireArguments().getString(ARGS_WALLET_NAME)) { "walletName is null" } - } - private var step by mutableStateOf(Step.Pending) private var errorInfo: String? by mutableStateOf(null) private var isLoading by mutableStateOf(true) - + private var latestMarkPrice by mutableStateOf("") private var latestUnrealizedPnl by mutableStateOf("") private var latestRoe by mutableStateOf("") + private var marketIconUrl by mutableStateOf("") + private var marketSymbol by mutableStateOf("") + private var settleAssetSymbol by mutableStateOf("USDT") + private var settleAssetItem by mutableStateOf(null) + private var sender by mutableStateOf(null) @Composable override fun ComposeContent() { - androidx.compose.runtime.LaunchedEffect(Unit) { + LaunchedEffect(Unit) { latestMarkPrice = markPrice latestUnrealizedPnl = unrealizedPnl latestRoe = roe } - - androidx.compose.runtime.LaunchedEffect(positionId) { + + LaunchedEffect(positionId) { viewModel.loadPositionDetail( positionId = positionId, onSuccess = { position -> latestMarkPrice = position.markPrice latestUnrealizedPnl = position.unrealizedPnl latestRoe = position.roe - isLoading = false + + viewModel.loadMarketDetail( + marketId = position.productId, + onSuccess = { market -> + marketIconUrl = market.iconUrl + marketSymbol = market.displaySymbol + }, + onError = {} + ) + + lifecycleScope.launch { + val asset = bottomViewModel.findAssetItemById(position.settleAssetId) + asset?.let { + settleAssetSymbol = it.symbol + settleAssetItem = it + } + + sender = bottomViewModel.refreshUser(position.botId) + + isLoading = false + } }, onError = { error -> Timber.e("Failed to load position detail: $error") @@ -208,7 +217,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen .fillMaxHeight() .background(MixinAppTheme.colors.background), ) { - WalletLabel(walletName = walletName, isWeb3 = false) + WalletLabel(walletName = getString(R.string.Privacy_Wallet), isWeb3 = false) Column( modifier = Modifier .verticalScroll(rememberScrollState()) @@ -223,6 +232,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen color = MixinAppTheme.colors.accent, ) } + Step.Error -> { androidx.compose.material.Icon( modifier = Modifier.size(70.dp), @@ -231,6 +241,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen tint = Color.Unspecified, ) } + Step.Done -> { androidx.compose.material.Icon( modifier = Modifier.size(70.dp), @@ -239,20 +250,32 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen tint = Color.Unspecified, ) } + else -> { - Box( - modifier = Modifier - .size(70.dp) - .clip(CircleShape) - .background(MixinAppTheme.colors.backgroundWindow), - contentAlignment = Alignment.Center - ) { - Text( - text = marketSymbol.take(3), - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = MixinAppTheme.colors.accent + if (marketIconUrl.isNotEmpty()) { + CoilImage( + model = marketIconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(70.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop ) + } else { + Box( + modifier = Modifier + .size(70.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow), + contentAlignment = Alignment.Center + ) { + Text( + text = marketSymbol.take(3), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.accent + ) + } } } } @@ -260,8 +283,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Text( text = stringResource( id = when (step) { - Step.Pending -> R.string.Perpetual - Step.Done -> R.string.web3_sending_success + Step.Pending -> R.string.Confirm_Close_Position + Step.Done -> R.string.Close_Position_Success Step.Error -> R.string.swap_failed Step.Sending -> R.string.Sending } @@ -275,7 +298,13 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Box(modifier = Modifier.height(8.dp)) Text( modifier = Modifier.padding(horizontal = 24.dp), - text = errorInfo ?: "$marketSymbol - USD", + text = errorInfo ?: stringResource( + id = when (step) { + Step.Done -> R.string.swap_message_success + Step.Error -> R.string.Data_error + else -> R.string.swap_inner_desc + } + ), textAlign = TextAlign.Center, style = TextStyle( color = if (errorInfo != null) MixinAppTheme.colors.tipError else MixinAppTheme.colors.textMinor, @@ -305,14 +334,21 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } else { MixinAppTheme.colors.walletRed } - + val estimatedReceive = try { - val margin = BigDecimal(quantity) - margin + pnl + val margin = BigDecimal(margin) + val unrealizedPnl = BigDecimal(latestUnrealizedPnl) + margin + unrealizedPnl } catch (e: Exception) { BigDecimal.ZERO } + val formattedRoe = try { + String.format("%.2f", latestRoe.toDouble()) + } catch (e: Exception) { + latestRoe + } + if (isLoading) { Box( modifier = Modifier @@ -326,37 +362,78 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) } } else { - CloseInfoItem( - title = "Estimate Receive".uppercase(), - value = "${if (estimatedReceive >= BigDecimal.ZERO) "+" else ""}${String.format("%.2f", estimatedReceive)} USDT", - valueColor = if (estimatedReceive >= BigDecimal.ZERO) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, - subValue = "Ethereum" - ) - Box(modifier = Modifier.height(20.dp)) - - CloseInfoItem( - title = "PnL".uppercase(), - value = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} USDT (${latestRoe}%)", - valueColor = pnlColor - ) - Box(modifier = Modifier.height(20.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = stringResource(R.string.Perpetual), + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + CoilImage( + model = marketIconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = marketSymbol, + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + Box(modifier = Modifier.height(20.dp)) + settleAssetItem?.let { asset -> + Text( + text = stringResource(R.string.Estimated_Receive), + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.W400 + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = asset.chainName ?: "", + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp + ) + } + } - CloseInfoItem( - title = stringResource(R.string.Receiver).uppercase(), - value = walletName - ) + Spacer(modifier = Modifier.height(4.dp)) + Row { + Text( + text = "${stringResource(R.string.Perpetual_PnL)}: ", + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp + ) + Text( + text = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} $settleAssetSymbol ($formattedRoe%)", + color = pnlColor, + fontSize = 14.sp + ) + } + } Box(modifier = Modifier.height(20.dp)) - CloseInfoItem( - title = stringResource(R.string.Sender).uppercase(), - value = "Mixin Futures (7000105155)" - ) + ItemWalletContent(title = stringResource(id = R.string.Receiver).uppercase(), fontSize = 16.sp) Box(modifier = Modifier.height(20.dp)) - CloseInfoItem( - title = "Memo".uppercase(), - value = positionId - ) + ItemUserContent(title = stringResource(id = R.string.Sender).uppercase(), sender, null) } Box(modifier = Modifier.height(16.dp)) } @@ -387,6 +464,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } } } + Step.Error -> { ActionBottom( modifier = Modifier.align(Alignment.BottomCenter), @@ -396,6 +474,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen confirmAction = { closePosition() }, ) } + Step.Pending -> { ActionBottom( modifier = Modifier.align(Alignment.BottomCenter), @@ -405,6 +484,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen confirmAction = { closePosition() }, ) } + Step.Sending -> {} } } @@ -418,7 +498,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen title: String, value: String, valueColor: Color = MixinAppTheme.colors.textPrimary, - subValue: String? = null + subValue: String? = null, ) { Column( modifier = Modifier @@ -467,7 +547,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen private fun closePosition() = lifecycleScope.launch(Dispatchers.IO) { try { step = Step.Sending - + viewModel.closePerpsOrder( positionId = positionId, onSuccess = { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 321fbf3193..c86051113e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2286,5 +2286,8 @@ Mixin Futures 确认开仓 开仓成功 + 确认平仓 + 平仓成功 + 平仓 预估强平价格 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f292e57ba9..0fa16a948d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2350,5 +2350,8 @@ Funding Rate Confirm Open Position Open Position Success + Confirm Close Position + Close Position Success + Close Position Estimated Liquidation Price From a35b59434e4f29fe4fdeced441e5c41a91323800 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sat, 28 Feb 2026 12:33:12 +0800 Subject: [PATCH 011/105] Update perpetual page --- .../api/response/perps/PositionHistoryView.kt | 6 +- .../java/one/mixin/android/db/TokenDao.kt | 4 + .../android/repository/TokenRepository.kt | 2 + .../android/ui/common/BottomSheetViewModel.kt | 2 + .../ui/home/web3/trade/ClosedPositionItem.kt | 139 +++++ .../ui/home/web3/trade/MarketDetailPage.kt | 2 + .../android/ui/home/web3/trade/MarketItem.kt | 140 +++++ .../ui/home/web3/trade/OpenPositionPage.kt | 110 +--- .../ui/home/web3/trade/PerpetualContent.kt | 573 +++++++++--------- .../ui/home/web3/trade/PerpetualViewModel.kt | 45 ++ .../ui/home/web3/trade/PerpsActivity.kt | 28 +- .../android/ui/home/web3/trade/TradePage.kt | 6 - .../TokenListBottomSheetDialogFragment.kt | 39 +- app/src/main/res/values-zh-rCN/strings.xml | 9 + app/src/main/res/values/strings.xml | 8 + 15 files changed, 709 insertions(+), 404 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt index 4d5b1787b9..68fe8753bc 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt @@ -7,8 +7,10 @@ data class PositionHistoryView( val historyId: String, @SerializedName("position_id") val positionId: String, + @SerializedName("product_id") + val productId: String, @SerializedName("market_symbol") - val marketSymbol: String, + val marketSymbol: String? = null, @SerializedName("side") val side: String, @SerializedName("quantity") @@ -21,6 +23,8 @@ data class PositionHistoryView( val realizedPnl: String, @SerializedName("leverage") val leverage: Int, + @SerializedName("margin_method") + val marginMethod: String? = null, @SerializedName("open_at") val openAt: String, @SerializedName("closed_at") diff --git a/app/src/main/java/one/mixin/android/db/TokenDao.kt b/app/src/main/java/one/mixin/android/db/TokenDao.kt index 6b03f2b2c1..e20aa3d4dd 100644 --- a/app/src/main/java/one/mixin/android/db/TokenDao.kt +++ b/app/src/main/java/one/mixin/android/db/TokenDao.kt @@ -139,6 +139,10 @@ interface TokenDao : BaseDao { @Query("$PREFIX_ASSET_ITEM WHERE ae.balance > 0 AND (ae.hidden IS NULL OR NOT ae.hidden) $POSTFIX_ASSET_ITEM") fun assetItemsWithBalance(defaultIconUrl: String = Constants.DEFAULT_ICON_URL): LiveData> + @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) + @Query("$PREFIX_ASSET_ITEM WHERE ae.balance > 0 AND (ae.hidden IS NULL OR NOT ae.hidden) AND a1.asset_id IN (:usdAssetIds) $POSTFIX_ASSET_ITEM") + fun usdAssetItemsWithBalance(usdAssetIds: List, defaultIconUrl: String = Constants.DEFAULT_ICON_URL): LiveData> + @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("$PREFIX_ASSET_ITEM WHERE ae.balance > 0 AND (ae.hidden IS NULL OR NOT ae.hidden) $POSTFIX_ASSET_ITEM") suspend fun findAssetItemsWithBalance(defaultIconUrl: String = Constants.DEFAULT_ICON_URL): List diff --git a/app/src/main/java/one/mixin/android/repository/TokenRepository.kt b/app/src/main/java/one/mixin/android/repository/TokenRepository.kt index 65765b9a1c..7b728d9d55 100644 --- a/app/src/main/java/one/mixin/android/repository/TokenRepository.kt +++ b/app/src/main/java/one/mixin/android/repository/TokenRepository.kt @@ -560,6 +560,8 @@ class TokenRepository fun assetItemsWithBalance() = tokenDao.assetItemsWithBalance() + fun usdAssetItemsWithBalance() = tokenDao.usdAssetItemsWithBalance(Constants.usdIds) + fun allSnapshots(filterParams: FilterParams): DataSource.Factory { return safeSnapshotDao.getSnapshots(filterParams.buildQuery()).map { if (!it.withdrawal?.receiver.isNullOrBlank()) { diff --git a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt index 9cf6f12aac..6ee7d9ecba 100644 --- a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt @@ -216,6 +216,8 @@ class BottomSheetViewModel fun assetItemsWithBalance(): LiveData> = tokenRepository.assetItemsWithBalance() + fun usdAssetItemsWithBalance(): LiveData> = tokenRepository.usdAssetItemsWithBalance() + fun assetItemsNotHidden(): LiveData> = tokenRepository.assetItemsNotHidden() suspend fun kernelWithdrawalTransaction( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt new file mode 100644 index 0000000000..5c2a7ab46a --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -0,0 +1,139 @@ +package one.mixin.android.ui.home.web3.trade + +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.R +import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.ui.wallet.alert.components.cardBackground +import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.util.Locale + +@Composable +fun ClosedPositionItem(position: PositionHistoryView) { + val pnl = try { + BigDecimal(position.realizedPnl) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isProfit = pnl >= BigDecimal.ZERO + val pnlColor = if (isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) + val pnlText = "${if (isProfit) "+" else ""}$${String.format("%.2f", pnl)}" + + val entryPrice = try { + val price = BigDecimal(position.entryPrice) + String.format("%.4f", price) + } catch (e: Exception) { + position.entryPrice + } + + val closePrice = try { + val price = BigDecimal(position.closePrice) + String.format("%.4f", price) + } catch (e: Exception) { + position.closePrice + } + + val closedDate = try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + val outputFormat = SimpleDateFormat("MMM dd, HH:mm", Locale.US) + val date = inputFormat.parse(position.closedAt) + date?.let { outputFormat.format(it) } ?: position.closedAt + } catch (e: Exception) { + position.closedAt + } + + val displaySymbol = position.marketSymbol ?: "Unknown" + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = displaySymbol, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = position.side.uppercase(), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = if (position.side.lowercase() == "long") Color(0xFF4CAF50) else Color(0xFFF44336), + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .cardBackground( + if (position.side.lowercase() == "long") Color(0xFF4CAF50).copy(alpha = 0.1f) else Color(0xFFF44336).copy(alpha = 0.1f), + Color.Transparent + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${position.leverage}x", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.Entry_Close_Price, entryPrice, closePrice), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = closedDate, + fontSize = 11.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = pnlText, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = pnlColor, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${position.quantity} ${displaySymbol}", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt index e794fed296..176d86a064 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -196,6 +196,7 @@ fun MarketDetailPage( context = context, marketId = marketId, marketSymbol = marketSymbol, + marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, isLong = true ) }, @@ -227,6 +228,7 @@ fun MarketDetailPage( context = context, marketId = marketId, marketSymbol = marketSymbol, + marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, isLong = false ) }, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt new file mode 100644 index 0000000000..79213ceda5 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt @@ -0,0 +1,140 @@ +package one.mixin.android.ui.home.web3.trade + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.numberFormatCompact +import one.mixin.android.ui.wallet.alert.components.cardBackground +import java.math.BigDecimal + +@Composable +fun MarketItem( + market: PerpsMarket, + onClick: () -> Unit = {} +) { + val change = try { + BigDecimal(market.change) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isPositive = change >= BigDecimal.ZERO + val changeColor = if (isPositive) Color(0xFF4CAF50) else Color(0xFFF44336) + val changeText = "${if (isPositive) "+" else ""}${market.change}%" + + val formattedPrice = try { + val price = BigDecimal(market.markPrice) + if (price >= BigDecimal("1000")) { + String.format("%.2f", price) + } else if (price >= BigDecimal("1")) { + String.format("%.4f", price) + } else { + String.format("%.6f", price) + } + } catch (e: Exception) { + market.markPrice + } + + val formattedVolume = try { + BigDecimal(market.volume).numberFormatCompact() + } catch (e: Exception) { + market.volume + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + CoilImage( + model = market.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = market.displaySymbol, + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "${market.leverage}x", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .cardBackground( + MixinAppTheme.colors.backgroundGrayLight, + Color.Transparent + ) + .padding(horizontal = 3.dp, vertical = 1.dp) + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.Vol, formattedVolume), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = "$$formattedPrice", + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = changeText, + fontSize = 12.sp, + color = changeColor, + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index 60a5057ecf..f85d5e62a9 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -69,14 +69,15 @@ import kotlin.math.abs fun OpenPositionPage( marketId: String, marketSymbol: String, + displaySymbol: String, isLong: Boolean, onBack: () -> Unit, + onTokenSelect: () -> Unit = {}, ) { val context = LocalContext.current val viewModel = hiltViewModel() val coroutineScope = rememberCoroutineScope() val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) - val tokenBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) var market by remember { mutableStateOf(null) } var selectedToken by remember { mutableStateOf(null) } @@ -106,29 +107,18 @@ fun OpenPositionPage( val leverageOptions = generateLeverageOptions(maxLeverage) ModalBottomSheetLayout( - sheetState = if (tokenBottomSheetState.isVisible) tokenBottomSheetState else bottomSheetState, + sheetState = bottomSheetState, sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetBackgroundColor = MixinAppTheme.colors.background, sheetContent = { - if (tokenBottomSheetState.isVisible) { - TokenSelectionBottomSheet( - tokens = availableTokens, - selectedToken = selectedToken, - onTokenSelect = { token -> - selectedToken = token - coroutineScope.launch { tokenBottomSheetState.hide() } - } - ) - } else { - LeverageBottomSheet( - currentLeverage = leverage, - maxLeverage = maxLeverage, - onLeverageChange = { - leverage = it - coroutineScope.launch { bottomSheetState.hide() } - } - ) - } + LeverageBottomSheet( + currentLeverage = leverage, + maxLeverage = maxLeverage, + onLeverageChange = { + leverage = it + coroutineScope.launch { bottomSheetState.hide() } + } + ) } ) { MixinAppTheme { @@ -195,7 +185,7 @@ fun OpenPositionPage( token = selectedToken?.toSwapToken(), text = usdtAmount, selectClick = { - coroutineScope.launch { tokenBottomSheetState.show() } + onTokenSelect() }, onInputChanged = { usdtAmount = it } ) @@ -432,82 +422,6 @@ fun OpenPositionPage( } } -@Composable -private fun TokenSelectionBottomSheet( - tokens: List, - selectedToken: TokenItem?, - onTokenSelect: (TokenItem) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = stringResource(R.string.Select_Token), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = MixinAppTheme.colors.textPrimary - ) - - Spacer(modifier = Modifier.height(16.dp)) - - tokens.forEach { token -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { onTokenSelect(token) } - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CoilImage( - model = token.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier - .size(32.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = token.symbol, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary - ) - Text( - text = token.chainName ?: "", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - } - - Text( - text = token.balance, - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary - ) - - if (selectedToken?.assetId == token.assetId) { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - painter = painterResource(R.drawable.ic_check), - contentDescription = null, - tint = MixinAppTheme.colors.accent, - modifier = Modifier.size(20.dp) - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - } -} - @Composable private fun LeverageBottomSheet( currentLeverage: Float, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index 9d0e803124..f16d123791 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -1,6 +1,7 @@ package one.mixin.android.ui.home.web3.trade import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,11 +15,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.ModalBottomSheetLayout @@ -36,32 +37,28 @@ 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch -import one.mixin.android.compose.CoilImage -import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.api.response.perps.PositionHistoryView import one.mixin.android.compose.theme.MixinAppTheme -import one.mixin.android.extension.numberFormatCompact -import one.mixin.android.extension.openUrl +import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground -import java.math.BigDecimal @OptIn(ExperimentalMaterialApi::class) @Composable fun PerpetualContent( - onLongClick: (PerpsMarket) -> Unit, - onShortClick: (PerpsMarket) -> Unit, - onShowTradingGuide: () -> Unit + onShowTradingGuide: () -> Unit, ) { + val walletId = Session.getAccountId()!! val context = LocalContext.current val viewModel = hiltViewModel() val coroutineScope = rememberCoroutineScope() @@ -71,9 +68,10 @@ fun PerpetualContent( var errorMessage by remember { mutableStateOf(null) } var openPositionsCount by remember { mutableStateOf(0) } var totalPnl by remember { mutableStateOf(0.0) } - + var closedPositions by remember { mutableStateOf>(emptyList()) } + var isLoadingHistory by remember { mutableStateOf(false) } + val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) - var isLongMode by remember { mutableStateOf(true) } LaunchedEffect(Unit) { viewModel.loadMarkets( @@ -86,16 +84,28 @@ fun PerpetualContent( isLoading = false } ) - - val walletId = one.mixin.android.web3.js.Web3Signer.currentWalletId + if (walletId.isNotEmpty()) { viewModel.getOpenPositions(walletId) { positions -> openPositionsCount = positions.size } - + viewModel.getTotalUnrealizedPnl(walletId) { pnl -> totalPnl = pnl } + + isLoadingHistory = true + viewModel.loadPositionHistory( + walletId = walletId, + limit = 10, + onSuccess = { history -> + closedPositions = history + isLoadingHistory = false + }, + onError = { error -> + isLoadingHistory = false + } + ) } } @@ -109,325 +119,316 @@ fun PerpetualContent( onMarketClick = { market -> coroutineScope.launch { bottomSheetState.hide() - PerpsActivity.showDetail(context, market.marketId, market.symbol) + PerpsActivity.showDetail(context, market.marketId, market.symbol, market.displaySymbol) } } ) } ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Column( - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) - .padding(16.dp), - ) { - Text( - text = "Total Position Value", - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "$${String.format("%.2f", totalPnl)}", - fontSize = 18.sp, - fontWeight = FontWeight.W600, - color = MixinAppTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "$${String.format("%.2f", totalPnl)}", - fontSize = 14.sp, - color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "(${if (totalPnl >= 0) "+" else ""}${String.format("%.1f", totalPnl)}%)", - fontSize = 14.sp, - color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) - .padding(16.dp), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Open Positions($openPositionsCount)", - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary, - ) - } Spacer(modifier = Modifier.height(16.dp)) + Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp), ) { - Icon( - painter = painterResource(id = R.drawable.ic_empty_transaction), - contentDescription = null, - tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(48.dp) - ) - Spacer(modifier = Modifier.height(12.dp)) Text( - text = "No Positions", + text = stringResource(R.string.Total_Position_Value), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onShowTradingGuide() - } - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { + Spacer(modifier = Modifier.height(8.dp)) Text( - text = "How perps works?", - fontSize = 14.sp, - color = MixinAppTheme.colors.accent, + text = String.format("$%.2f", totalPnl), + fontSize = 18.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = String.format("$%.2f", totalPnl), + fontSize = 14.sp, + color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = String.format("(%s%.1f%%)", if (totalPnl >= 0) "+" else "", totalPnl), + fontSize = 14.sp, + color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, + ) + } } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Column( Modifier - .fillMaxWidth() - .wrapContentHeight() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) - .padding(16.dp)) { - // Markets Section - Text( - text = "Markets", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary, - ) - - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) - if (isLoading) { - Box( - modifier = Modifier + Column( + modifier = + Modifier .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.Open_Positions, openPositionsCount), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { + Icon( + painter = painterResource(id = R.drawable.ic_empty_transaction), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(78.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Loading...", + text = stringResource(R.string.No_Positions), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, ) } - } else if (errorMessage != null) { - Box( + + Spacer(modifier = Modifier.height(10.dp)) + + Row( modifier = Modifier .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { + onShowTradingGuide() + }) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = errorMessage ?: "Error loading markets", + text = stringResource(R.string.How_Perps_Works), fontSize = 14.sp, - color = MixinAppTheme.colors.red, - textAlign = TextAlign.Center + color = MixinAppTheme.colors.accent, ) } - } else { - markets.take(2).forEach { market -> - MarketItem( - market = market, - onClick = { - PerpsActivity.showDetail(context, market.marketId, market.symbol) - } - ) - Spacer(modifier = Modifier.height(12.dp)) - } } - } - Spacer(modifier = Modifier.height(24.dp)) - // Long and Short Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Button( - onClick = { - isLongMode = true - coroutineScope.launch { - bottomSheetState.show() - } - }, - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = Color(0xFF4CAF50), - contentColor = Color.White - ), - enabled = markets.isNotEmpty() - ) { - Text( - text = "Long", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } + Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { - isLongMode = false - coroutineScope.launch { - bottomSheetState.show() - } - }, - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = Color(0xFFF44336), - contentColor = Color.White - ), - enabled = markets.isNotEmpty() + Column( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp) ) { - Text( - text = "Short", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - } - } -} + // Markets Section + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.Markets), + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) -@Composable -fun MarketItem(market: PerpsMarket, onClick: () -> Unit = {}) { - val change = try { - BigDecimal(market.change) - } catch (e: Exception) { - BigDecimal.ZERO - } + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MixinAppTheme.colors.accent, + modifier = Modifier.size(32.dp) + ) + } + } else if (errorMessage != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = errorMessage ?: "Error loading markets", + fontSize = 14.sp, + color = MixinAppTheme.colors.red, + textAlign = TextAlign.Center + ) + } + } else { + markets.take(2).forEach { market -> + MarketItem( + market = market, + onClick = { + PerpsActivity.showDetail(context, market.marketId, market.symbol, market.displaySymbol) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } - val isPositive = change >= BigDecimal.ZERO - val changeColor = if (isPositive) Color(0xFF4CAF50) else Color(0xFFF44336) - val changeText = "${if (isPositive) "+" else ""}${market.change}%" + Spacer(modifier = Modifier.height(16.dp)) - val formattedPrice = try { - val price = BigDecimal(market.markPrice) - if (price >= BigDecimal("1000")) { - String.format("%.2f", price) - } else if (price >= BigDecimal("1")) { - String.format("%.4f", price) - } else { - String.format("%.6f", price) - } - } catch (e: Exception) { - market.markPrice - } + Column( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.Closed_Positions), + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) - // 使用 numberFormatCompact 格式化成交量 - val formattedVolume = try { - BigDecimal(market.volume).numberFormatCompact() - } catch (e: Exception) { - market.volume - } + if (isLoadingHistory) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MixinAppTheme.colors.accent, + modifier = Modifier.size(32.dp) + ) + } + } else if (closedPositions.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.ic_empty_transaction), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.No_Closed_Positions), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } + } else { + closedPositions.forEach { position -> + ClosedPositionItem(position = position) + Spacer(modifier = Modifier.height(12.dp)) + } + } + } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - CoilImage( - model = market.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) + Spacer(modifier = Modifier.height(24.dp)) - Spacer(modifier = Modifier.width(12.dp)) + // Long and Short Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + coroutineScope.launch { + bottomSheetState.show() + } + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color(0xFF4CAF50), + contentColor = Color.White + ), + enabled = markets.isNotEmpty() + ) { + Text( + text = stringResource(R.string.Long), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } - Column { - Text( - text = market.displaySymbol, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "Vol $formattedVolume", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist, - ) + Button( + onClick = { + coroutineScope.launch { + bottomSheetState.show() + } + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color(0xFFF44336), + contentColor = Color.White + ), + enabled = markets.isNotEmpty() + ) { + Text( + text = stringResource(R.string.Short), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } } - } - Column( - horizontalAlignment = Alignment.End - ) { - Text( - text = "$$formattedPrice", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = changeText, - fontSize = 12.sp, - color = changeColor, - fontWeight = FontWeight.Medium - ) + Spacer(modifier = Modifier.height(24.dp)) } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index 16bdb9a1f7..0a2e6ffc21 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -348,4 +348,49 @@ class PerpetualViewModel @Inject constructor( } } } + + fun loadPositionHistory( + walletId: String, + limit: Int = 10, + offset: String? = null, + onSuccess: (List) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + routeService.getPerpsPositionHistory( + walletId = walletId, + limit = limit, + offset = offset + ) + } + + val data = response.data + if (response.isSuccess && data != null) { + Timber.d("Position history loaded: ${data.size} items") + + val markets = withContext(Dispatchers.IO) { + perpsMarketDao.getAllMarkets() + } + val marketMap = markets.associateBy { it.marketId } + + val enrichedData = data.map { history -> + val market = marketMap[history.productId] + history.copy(marketSymbol = market?.displaySymbol ?: history.productId) + } + + onSuccess(enrichedData) + } else { + val error = "Failed to load position history: ${response.errorDescription}" + Timber.e(error) + onError(error) + } + } catch (e: Exception) { + val error = "Error loading position history: ${e.message}" + Timber.e(e, error) + onError(error) + } + } + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt index 7a1ab06076..64d8c74569 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt @@ -4,13 +4,17 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshPerpsPositionsJob import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseActivity -import one.mixin.android.web3.js.Web3Signer +import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment +import one.mixin.android.vo.safe.TokenItem import javax.inject.Inject @AndroidEntryPoint @@ -19,28 +23,33 @@ class PerpsActivity : BaseActivity() { @Inject lateinit var jobManager: MixinJobManager + private var selectedToken by mutableStateOf(null) + companion object { private const val EXTRA_MARKET_ID = "extra_market_id" private const val EXTRA_MARKET_SYMBOL = "extra_market_symbol" + private const val EXTRA_MARKET_DISPLAY_SYMBOL = "extra_market_display_symbol" private const val EXTRA_MODE = "extra_mode" private const val EXTRA_IS_LONG = "extra_is_long" const val MODE_DETAIL = "detail" const val MODE_OPEN_POSITION = "open_position" - fun showDetail(context: Context, marketId: String, marketSymbol: String) { + fun showDetail(context: Context, marketId: String, marketSymbol: String, marketDisplaySymbol: String) { val intent = Intent(context, PerpsActivity::class.java).apply { putExtra(EXTRA_MARKET_ID, marketId) putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) + putExtra(EXTRA_MARKET_DISPLAY_SYMBOL, marketDisplaySymbol) putExtra(EXTRA_MODE, MODE_DETAIL) } context.startActivity(intent) } - fun showOpenPosition(context: Context, marketId: String, marketSymbol: String, isLong: Boolean) { + fun showOpenPosition(context: Context, marketId: String, marketSymbol: String, marketDisplaySymbol: String, isLong: Boolean) { val intent = Intent(context, PerpsActivity::class.java).apply { putExtra(EXTRA_MARKET_ID, marketId) putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) + putExtra(EXTRA_MARKET_DISPLAY_SYMBOL, marketDisplaySymbol) putExtra(EXTRA_MODE, MODE_OPEN_POSITION) putExtra(EXTRA_IS_LONG, isLong) } @@ -53,6 +62,7 @@ class PerpsActivity : BaseActivity() { val marketId = intent.getStringExtra(EXTRA_MARKET_ID) ?: "" val marketSymbol = intent.getStringExtra(EXTRA_MARKET_SYMBOL) ?: "" + val displaySymbol = intent.getStringExtra(EXTRA_MARKET_DISPLAY_SYMBOL) ?: "" val mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_DETAIL val isLong = intent.getBooleanExtra(EXTRA_IS_LONG, true) @@ -65,8 +75,10 @@ class PerpsActivity : BaseActivity() { OpenPositionPage( marketId = marketId, marketSymbol = marketSymbol, + displaySymbol = displaySymbol, isLong = isLong, - onBack = { finish() } + onBack = { finish() }, + onTokenSelect = { showTokenSelection() } ) } @@ -82,6 +94,14 @@ class PerpsActivity : BaseActivity() { } } + private fun showTokenSelection() { + TokenListBottomSheetDialogFragment.newInstance( + fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP + ).setOnAssetClick { token -> + selectedToken = token + }.show(supportFragmentManager, TokenListBottomSheetDialogFragment.TAG) + } + private fun refreshPositions() { val walletId = Session.getAccountId() walletId?.let { 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 d1481bd55a..042e29fc8e 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 @@ -167,12 +167,6 @@ fun TradePage( if (walletId == null) { tabs += TabItem(title = stringResource(R.string.Perpetual)) { PerpetualContent( - onLongClick = { _ -> - // TODO: Handle long position - }, - onShortClick = { _ -> - // TODO: Handle short position - }, onShowTradingGuide = onShowTradingGuide ) } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt index 32eb51d53c..46d39435be 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt @@ -63,6 +63,7 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { const val TYPE_FROM_SEND = 0 const val TYPE_FROM_RECEIVE = 1 const val TYPE_FROM_TRANSFER = 2 + const val TYPE_FROM_PERP = 3 const val ASSET_PREFERENCE = "TRANSFER_ASSET" @@ -86,6 +87,7 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { when (fromType) { TYPE_FROM_SEND, TYPE_FROM_TRANSFER -> Constants.Account.PREF_WALLET_SEND TYPE_FROM_RECEIVE -> Constants.Account.PREF_WALLET_RECEIVE + TYPE_FROM_PERP -> Constants.Account.PREF_WALLET_SEND else -> Constants.Account.PREF_WALLET_SEND } } @@ -100,6 +102,10 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { private fun initRadio() { binding.apply { + if (fromType == TYPE_FROM_PERP) { + radio.isVisible = false + return + } radio.isVisible = true radioAll.isChecked = true radio.scrollToCenterCheckedRadio(radioGroup) @@ -227,6 +233,8 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { if (fromType == TYPE_FROM_SEND || fromType == TYPE_FROM_TRANSFER) { bottomViewModel.assetItemsWithBalance() + } else if (fromType == TYPE_FROM_PERP) { + bottomViewModel.usdAssetItemsWithBalance() } else { bottomViewModel.assetItemsNotHidden() }.observe(this) { @@ -252,13 +260,16 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { override fun onStart() { super.onStart() + if (fromType == TYPE_FROM_PERP) { + return + } binding.apply { root.findViewById(composeId).let { if (it == null) { val composeView = ComposeView(requireContext()).apply { id = View.generateViewId() setContent { - RecentTokens (false, key) { + RecentTokens(false, key) { defaultSharedPreferences.addToList(key, it.assetId) if (asyncOnAsset != null) { asyncClick(it) @@ -308,15 +319,23 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { binding.rvVa.displayedChild = POS_RV binding.pb.isVisible = true - val localAssets = bottomViewModel.fuzzySearchAssets(query)?.filter { - if (TYPE_FROM_SEND == fromType) { - it.balance.toBigDecimalOrNull().run { this != null && this > BigDecimal.ZERO } - } else { - true + val localAssets = if (TYPE_FROM_PERP == fromType) { + defaultAssets.filter { token -> + token.symbol.containsIgnoreCase(query) || token.name.containsIgnoreCase(query) + } + } else { + bottomViewModel.fuzzySearchAssets(query)?.filter { + if (TYPE_FROM_SEND == fromType) { + it.balance.toBigDecimalOrNull().run { this != null && this > BigDecimal.ZERO } + } else { + true + } } } - adapter.submitList(localAssets) - val remoteAssets = if (TYPE_FROM_SEND == fromType) emptyList() else + + val remoteAssets = if (TYPE_FROM_SEND == fromType || TYPE_FROM_PERP == fromType) { + emptyList() + } else { bottomViewModel.queryAsset(walletId = null, query = query).map { val local = bottomViewModel.findAssetItemById(it.assetId) if (local != null) { @@ -325,6 +344,8 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { it } } + } + val result = sortQueryAsset(query, localAssets, remoteAssets) adapter.submitList(result) { @@ -335,7 +356,7 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { if (localAssets.isNullOrEmpty() && remoteAssets.isEmpty()) { binding.rvVa.displayedChild = POS_EMPTY_RECEIVE } - + if (!isAdded) return@launch loadData() } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3ad6ab54a6..5d70959770 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2290,6 +2290,15 @@ 平仓成功 平仓 预估强平价格 + 持仓总价值 + 持仓中(%1$d) + 已平仓 + 暂无持仓 + 暂无已平仓记录 + 加载中... + 成交量 %1$s + 开仓: $%1$s → 平仓: $%2$s + 永续合约如何运作? 搜索… 未检测到sim卡。 选择一个国家或地区 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 432439f770..e15dc17100 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2355,6 +2355,14 @@ Close Position Success Close Position Estimated Liquidation Price + Total Position Value + Open Positions(%1$d) + Closed Positions + No Positions + No Closed Positions + Loading... + Vol %1$s + Entry: $%1$s → Close: $%2$s Search… No sim card detected. Choose a Country or Region From 4c6ad839b5cb42737679a91cccffdc63ec8cab78 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sat, 28 Feb 2026 15:44:20 +0800 Subject: [PATCH 012/105] Update market bottom --- .../ui/home/web3/trade/MarketListAdapter.kt | 92 +++ .../MarketListBottomSheetDialogFragment.kt | 118 ++++ .../ui/home/web3/trade/PerpetualContent.kt | 529 +++++++++--------- .../ui/home/web3/trade/TradeFragment.kt | 3 + .../android/ui/home/web3/trade/TradePage.kt | 4 +- .../fragment_market_list_bottom_sheet.xml | 100 ++++ app/src/main/res/layout/item_market_list.xml | 74 +++ app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 643 insertions(+), 281 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt create mode 100644 app/src/main/res/layout/fragment_market_list_bottom_sheet.xml create mode 100644 app/src/main/res/layout/item_market_list.xml diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt new file mode 100644 index 0000000000..72cf8c9fec --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt @@ -0,0 +1,92 @@ +package one.mixin.android.ui.home.web3.trade + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.databinding.ItemMarketListBinding +import one.mixin.android.extension.loadImage +import one.mixin.android.extension.numberFormatCompact +import java.math.BigDecimal + +class MarketListAdapter( + private val onMarketClick: (PerpsMarket) -> Unit +) : RecyclerView.Adapter() { + + private var markets = listOf() + + fun submitList(list: List) { + markets = list + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MarketViewHolder { + return MarketViewHolder( + ItemMarketListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: MarketViewHolder, position: Int) { + holder.bind(markets[position]) + } + + override fun getItemCount() = markets.size + + inner class MarketViewHolder( + private val binding: ItemMarketListBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(market: PerpsMarket) { + binding.apply { + iconIv.loadImage(market.iconUrl, R.drawable.ic_avatar_place_holder) + symbolTv.text = market.displaySymbol + + val formattedVolume = try { + BigDecimal(market.volume).numberFormatCompact() + } catch (e: Exception) { + market.volume + } + volumeTv.text = root.context.getString(R.string.Vol, formattedVolume) + + val formattedPrice = try { + val price = BigDecimal(market.markPrice) + when { + price >= BigDecimal("1000") -> String.format("$%.2f", price) + price >= BigDecimal("1") -> String.format("$%.4f", price) + else -> String.format("$%.6f", price) + } + } catch (e: Exception) { + market.markPrice + } + priceTv.text = formattedPrice + + val change = try { + BigDecimal(market.change) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isPositive = change >= BigDecimal.ZERO + val changeColor = if (isPositive) { + ContextCompat.getColor(root.context, R.color.wallet_green) + } else { + ContextCompat.getColor(root.context, R.color.wallet_red) + } + val changeText = "${if (isPositive) "+" else ""}${market.change}%" + + changeTv.text = changeText + changeTv.setTextColor(changeColor) + + root.setOnClickListener { + onMarketClick(market) + } + } + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..1150e5cc37 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt @@ -0,0 +1,118 @@ +package one.mixin.android.ui.home.web3.trade + +import android.annotation.SuppressLint +import android.app.Dialog +import android.text.Editable +import android.view.ViewGroup +import androidx.core.view.doOnPreDraw +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.databinding.FragmentMarketListBottomSheetBinding +import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.extension.appCompatActionBarHeight +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.withArgs +import one.mixin.android.ui.common.MixinBottomSheetDialogFragment +import one.mixin.android.util.viewBinding +import one.mixin.android.widget.BottomSheet +import javax.inject.Inject + +@AndroidEntryPoint +class MarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { + + companion object { + const val TAG = "MarketListBottomSheetDialogFragment" + private const val ARGS_IS_LONG = "args_is_long" + + fun newInstance(isLong: Boolean) = MarketListBottomSheetDialogFragment().withArgs { + putBoolean(ARGS_IS_LONG, isLong) + } + } + + private val binding by viewBinding(FragmentMarketListBottomSheetBinding::inflate) + private val adapter by lazy { MarketListAdapter { market -> onMarketClick(market) } } + + @Inject + lateinit var perpsMarketDao: PerpsMarketDao + + private val isLong by lazy { requireArguments().getBoolean(ARGS_IS_LONG, true) } + private var allMarkets = listOf() + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, style) + contentView = binding.root + + binding.ph.doOnPreDraw { + binding.ph.updateLayoutParams { + height = binding.ph.getSafeAreaInsetsTop() + requireContext().appCompatActionBarHeight() + } + } + + (dialog as BottomSheet).apply { + setCustomView(contentView) + } + + binding.apply { + closeIb.setOnClickListener { + dismiss() + } + + marketRv.layoutManager = LinearLayoutManager(requireContext()) + marketRv.adapter = adapter + + searchEt.listener = object : one.mixin.android.widget.SearchView.OnSearchViewListener { + override fun afterTextChanged(s: Editable?) { + filterMarkets(s?.toString() ?: "") + } + + override fun onSearch() {} + } + } + + loadLocalMarkets() + } + + private fun loadLocalMarkets() { + lifecycleScope.launch { + allMarkets = withContext(Dispatchers.IO) { + perpsMarketDao.getAllMarkets() + } + updateList(allMarkets) + } + } + + private fun filterMarkets(query: String) { + if (query.isEmpty()) { + updateList(allMarkets) + } else { + val filtered = allMarkets.filter { market -> + market.displaySymbol.contains(query, ignoreCase = true) || + market.symbol.contains(query, ignoreCase = true) + } + updateList(filtered) + } + } + + private fun updateList(markets: List) { + binding.rvVa.displayedChild = if (markets.isEmpty()) 1 else 0 + adapter.submitList(markets) + } + + private fun onMarketClick(market: PerpsMarket) { + PerpsActivity.showOpenPosition( + context = requireContext(), + marketId = market.marketId, + marketSymbol = market.symbol, + marketDisplaySymbol = market.displaySymbol, + isLong = isLong + ) + dismiss() + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index f16d123791..ee04cae020 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -20,18 +20,13 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,7 +40,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import kotlinx.coroutines.launch import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.perps.PositionHistoryView @@ -53,15 +47,14 @@ import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground -@OptIn(ExperimentalMaterialApi::class) @Composable fun PerpetualContent( onShowTradingGuide: () -> Unit, + onShowMarketList: (isLong: Boolean) -> Unit, ) { val walletId = Session.getAccountId()!! val context = LocalContext.current val viewModel = hiltViewModel() - val coroutineScope = rememberCoroutineScope() var markets by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } @@ -71,8 +64,6 @@ fun PerpetualContent( var closedPositions by remember { mutableStateOf>(emptyList()) } var isLoadingHistory by remember { mutableStateOf(false) } - val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) - LaunchedEffect(Unit) { viewModel.loadMarkets( onSuccess = { data -> @@ -109,326 +100,304 @@ fun PerpetualContent( } } - ModalBottomSheetLayout( - sheetState = bottomSheetState, - sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - sheetBackgroundColor = MixinAppTheme.colors.background, - sheetContent = { - MarketListBottomSheetContent( - markets = markets, - onMarketClick = { market -> - coroutineScope.launch { - bottomSheetState.hide() - PerpsActivity.showDetail(context, market.marketId, market.symbol, market.displaySymbol) - } - } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.Total_Position_Value), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = String.format("$%.2f", totalPnl), + fontSize = 18.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = String.format("$%.2f", totalPnl), + fontSize = 14.sp, + color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = String.format("(%s%.1f%%)", if (totalPnl >= 0) "+" else "", totalPnl), + fontSize = 14.sp, + color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, + ) + } } - ) { + + Spacer(modifier = Modifier.height(16.dp)) Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp), ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.Open_Positions, openPositionsCount), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary, + ) + } Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) - .padding(16.dp), + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { + Icon( + painter = painterResource(id = R.drawable.ic_empty_transaction), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(78.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.Total_Position_Value), + text = stringResource(R.string.No_Positions), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, ) - Spacer(modifier = Modifier.height(8.dp)) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { + onShowTradingGuide() + }) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.How_Perps_Works), + fontSize = 14.sp, + color = MixinAppTheme.colors.accent, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + + // Markets Section + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( - text = String.format("$%.2f", totalPnl), - fontSize = 18.sp, - fontWeight = FontWeight.W600, + text = stringResource(R.string.Markets), + fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) - Spacer(modifier = Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = String.format("$%.2f", totalPnl), - fontSize = 14.sp, - color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = String.format("(%s%.1f%%)", if (totalPnl >= 0) "+" else "", totalPnl), - fontSize = 14.sp, - color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, - ) - } + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp) + ) } + Spacer(modifier = Modifier.height(12.dp)) - Spacer(modifier = Modifier.height(16.dp)) - - Column( - modifier = - Modifier + if (isLoading) { + Box( + modifier = Modifier .fillMaxWidth() - .wrapContentHeight() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + .height(200.dp), + contentAlignment = Alignment.Center ) { - Text( - text = stringResource(R.string.Open_Positions, openPositionsCount), - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary, - ) - } - Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painter = painterResource(id = R.drawable.ic_empty_transaction), - contentDescription = null, - tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(78.dp) - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(R.string.No_Positions), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, + CircularProgressIndicator( + color = MixinAppTheme.colors.accent, + modifier = Modifier.size(32.dp) ) } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( + } else if (errorMessage != null) { + Box( modifier = Modifier .fillMaxWidth() - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { - onShowTradingGuide() - }) - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + .height(200.dp), + contentAlignment = Alignment.Center ) { Text( - text = stringResource(R.string.How_Perps_Works), + text = errorMessage ?: "Error loading markets", fontSize = 14.sp, - color = MixinAppTheme.colors.accent, + color = MixinAppTheme.colors.red, + textAlign = TextAlign.Center ) } + } else { + markets.take(2).forEach { market -> + MarketItem( + market = market, + onClick = { + PerpsActivity.showDetail(context, market.marketId, market.symbol, market.displaySymbol) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } } + } - Spacer(modifier = Modifier.height(16.dp)) - - Column( - Modifier - .fillMaxWidth() - .wrapContentHeight() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) - .padding(16.dp) + Spacer(modifier = Modifier.height(16.dp)) + // Closed position Section + Column( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { + Text( + text = stringResource(R.string.Closed_Positions), + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) - // Markets Section - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + if (isLoadingHistory) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center ) { - Text( - text = stringResource(R.string.Markets), - fontSize = 16.sp, - color = MixinAppTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - painter = painterResource(R.drawable.ic_arrow_right), - contentDescription = null, - tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(16.dp) + CircularProgressIndicator( + color = MixinAppTheme.colors.accent, + modifier = Modifier.size(32.dp) ) } - Spacer(modifier = Modifier.height(12.dp)) - - if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center + } else if (closedPositions.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally ) { - CircularProgressIndicator( - color = MixinAppTheme.colors.accent, - modifier = Modifier.size(32.dp) + Icon( + painter = painterResource(id = R.drawable.ic_empty_transaction), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(48.dp) ) - } - } else if (errorMessage != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center - ) { + Spacer(modifier = Modifier.height(8.dp)) Text( - text = errorMessage ?: "Error loading markets", + text = stringResource(R.string.No_Closed_Positions), fontSize = 14.sp, - color = MixinAppTheme.colors.red, - textAlign = TextAlign.Center + color = MixinAppTheme.colors.textAssist, ) } - } else { - markets.take(2).forEach { market -> - MarketItem( - market = market, - onClick = { - PerpsActivity.showDetail(context, market.marketId, market.symbol, market.displaySymbol) - } - ) - Spacer(modifier = Modifier.height(12.dp)) - } + } + } else { + closedPositions.forEach { position -> + ClosedPositionItem(position = position) + Spacer(modifier = Modifier.height(12.dp)) } } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) - Column( - Modifier - .fillMaxWidth() - .wrapContentHeight() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) - .padding(16.dp) + // Long and Short Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + onShowMarketList(true) + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color(0xFF4CAF50), + contentColor = Color.White + ), + enabled = markets.isNotEmpty() ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(R.string.Closed_Positions), - fontSize = 16.sp, - color = MixinAppTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - painter = painterResource(R.drawable.ic_arrow_right), - contentDescription = null, - tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(16.dp) - ) - } - Spacer(modifier = Modifier.height(12.dp)) - - if (isLoadingHistory) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - color = MixinAppTheme.colors.accent, - modifier = Modifier.size(32.dp) - ) - } - } else if (closedPositions.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painter = painterResource(id = R.drawable.ic_empty_transaction), - contentDescription = null, - tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(48.dp) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.No_Closed_Positions), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, - ) - } - } - } else { - closedPositions.forEach { position -> - ClosedPositionItem(position = position) - Spacer(modifier = Modifier.height(12.dp)) - } - } + Text( + text = stringResource(R.string.Long), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) } - Spacer(modifier = Modifier.height(24.dp)) - - // Long and Short Buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + Button( + onClick = { + onShowMarketList(false) + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = Color(0xFFF44336), + contentColor = Color.White + ), + enabled = markets.isNotEmpty() ) { - Button( - onClick = { - coroutineScope.launch { - bottomSheetState.show() - } - }, - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = Color(0xFF4CAF50), - contentColor = Color.White - ), - enabled = markets.isNotEmpty() - ) { - Text( - text = stringResource(R.string.Long), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } - - Button( - onClick = { - coroutineScope.launch { - bottomSheetState.show() - } - }, - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = Color(0xFFF44336), - contentColor = Color.White - ), - enabled = markets.isNotEmpty() - ) { - Text( - text = stringResource(R.string.Short), - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } + Text( + text = stringResource(R.string.Short), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) } - - Spacer(modifier = Modifier.height(24.dp)) } + + Spacer(modifier = Modifier.height(24.dp)) } } 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 4ed3557053..84113cf86a 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 @@ -332,6 +332,9 @@ class TradeFragment : BaseFragment() { }, pop = { navigateUp(navController) + }, + onShowMarketList = { isLong -> + MarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, MarketListBottomSheetDialogFragment.TAG) } ) } 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 042e29fc8e..e0f2711b79 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 @@ -89,6 +89,7 @@ fun TradePage( pop: () -> Unit, onLimitOrderClick: (String) -> Unit, onShowTradingGuide: () -> Unit, + onShowMarketList: (Boolean) -> Unit, ) { val context = LocalContext.current @@ -167,7 +168,8 @@ fun TradePage( if (walletId == null) { tabs += TabItem(title = stringResource(R.string.Perpetual)) { PerpetualContent( - onShowTradingGuide = onShowTradingGuide + onShowTradingGuide = onShowTradingGuide, + onShowMarketList = onShowMarketList ) } } diff --git a/app/src/main/res/layout/fragment_market_list_bottom_sheet.xml b/app/src/main/res/layout/fragment_market_list_bottom_sheet.xml new file mode 100644 index 0000000000..c20e3cbeb6 --- /dev/null +++ b/app/src/main/res/layout/fragment_market_list_bottom_sheet.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_market_list.xml b/app/src/main/res/layout/item_market_list.xml new file mode 100644 index 0000000000..529367ff32 --- /dev/null +++ b/app/src/main/res/layout/item_market_list.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5d70959770..41618be310 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -936,6 +936,7 @@ 搜索资产,联系人,消息 搜索消息 搜索名称, 符号 + 搜索市场 名称, 符号, 链接 名称 搜索 Mixin ID 或手机号码: @@ -2299,6 +2300,7 @@ 成交量 %1$s 开仓: $%1$s → 平仓: $%2$s 永续合约如何运作? + 选择市场 搜索… 未检测到sim卡。 选择一个国家或地区 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e15dc17100..3af94c1b80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -960,6 +960,7 @@ assets, contacts and messages SEARCH MESSAGES Search Name, Symbol + Search Market Name, Symbol, URL Name Search Mixin ID or phone number: @@ -2363,6 +2364,7 @@ Loading... Vol %1$s Entry: $%1$s → Close: $%2$s + Select Market Search… No sim card detected. Choose a Country or Region From 65ca95c1ecd49d3caaf4ff1355405e8eb8a31ce6 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sun, 1 Mar 2026 11:44:00 +0800 Subject: [PATCH 013/105] All positions page and detail --- .../api/response/perps/PositionHistoryView.kt | 10 +- .../web3/trade/AllClosedPositionsFragment.kt | 86 ++++++ .../home/web3/trade/ClosedPositionAdapter.kt | 119 ++++++++ .../ui/home/web3/trade/ClosedPositionItem.kt | 155 +++++----- .../ui/home/web3/trade/PerpetualContent.kt | 4 + .../ui/home/web3/trade/PerpetualViewModel.kt | 6 +- .../home/web3/trade/PositionDetailFragment.kt | 56 ++++ .../ui/home/web3/trade/PositionDetailPage.kt | 269 ++++++++++++++++++ .../ui/home/web3/trade/TradeFragment.kt | 3 + .../android/ui/home/web3/trade/TradePage.kt | 4 +- .../main/res/drawable/bg_round_gray_4dp.xml | 5 + .../layout/fragment_all_closed_positions.xml | 44 +++ .../res/layout/item_closed_position_list.xml | 96 +++++++ app/src/main/res/values-zh-rCN/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + 15 files changed, 802 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/AllClosedPositionsFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt create mode 100644 app/src/main/res/drawable/bg_round_gray_4dp.xml create mode 100644 app/src/main/res/layout/fragment_all_closed_positions.xml create mode 100644 app/src/main/res/layout/item_closed_position_list.xml diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt index 68fe8753bc..b5c2b0023b 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt @@ -1,7 +1,10 @@ package one.mixin.android.api.response.perps +import android.os.Parcelable import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +@Parcelize data class PositionHistoryView( @SerializedName("history_id") val historyId: String, @@ -28,5 +31,8 @@ data class PositionHistoryView( @SerializedName("open_at") val openAt: String, @SerializedName("closed_at") - val closedAt: String -) \ No newline at end of file + val closedAt: String, + var displaySymbol: String? = null, + var iconUrl: String? = null, + var tokenSymbol: String? = null +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllClosedPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllClosedPositionsFragment.kt new file mode 100644 index 0000000000..655e1c16dd --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllClosedPositionsFragment.kt @@ -0,0 +1,86 @@ +package one.mixin.android.ui.home.web3.trade + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import one.mixin.android.R +import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.databinding.FragmentAllClosedPositionsBinding +import one.mixin.android.session.Session +import one.mixin.android.ui.common.BaseFragment +import one.mixin.android.util.viewBinding + +@AndroidEntryPoint +class AllClosedPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions) { + + companion object { + const val TAG = "AllClosedPositionsFragment" + + fun newInstance() = AllClosedPositionsFragment() + } + + private val binding by viewBinding(FragmentAllClosedPositionsBinding::bind) + private val viewModel by viewModels() + + private val adapter by lazy { + ClosedPositionAdapter { position -> + activity?.supportFragmentManager?.let { fm -> + fm.beginTransaction() + .add(android.R.id.content, PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) + .addToBackStack(null) + .commit() + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.apply { + titleView.leftIb.setOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } + titleView.setSubTitle(getString(R.string.Closed_Positions), "") + + positionsRv.layoutManager = LinearLayoutManager(requireContext()) + positionsRv.adapter = adapter + } + + loadClosedPositions() + } + + private fun loadClosedPositions() { + val walletId = Session.getAccountId() ?: return + + binding.progressBar.isVisible = true + binding.emptyView.root.isVisible = false + + lifecycleScope.launch { + viewModel.loadPositionHistory( + walletId = walletId, + limit = 100, + onSuccess = { positions -> + binding.progressBar.isVisible = false + if (positions.isEmpty()) { + binding.emptyView.root.isVisible = true + binding.positionsRv.isVisible = false + } else { + binding.emptyView.root.isVisible = false + binding.positionsRv.isVisible = true + adapter.submitList(positions) + } + }, + onError = { + binding.progressBar.isVisible = false + binding.emptyView.root.isVisible = true + binding.positionsRv.isVisible = false + } + ) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt new file mode 100644 index 0000000000..29d1245083 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt @@ -0,0 +1,119 @@ +package one.mixin.android.ui.home.web3.trade + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import one.mixin.android.R +import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.databinding.ItemClosedPositionListBinding +import one.mixin.android.extension.loadImage +import java.math.BigDecimal + +class ClosedPositionAdapter( + private val onItemClick: ((PositionHistoryView) -> Unit)? = null +) : ListAdapter(DiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + ItemClosedPositionListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + onItemClick + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class ViewHolder( + private val binding: ItemClosedPositionListBinding, + private val onItemClick: ((PositionHistoryView) -> Unit)? + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(position: PositionHistoryView) { + binding.apply { + val context = binding.root.context + + root.setOnClickListener { + onItemClick?.invoke(position) + } + + iconIv.loadImage(position.iconUrl, R.drawable.ic_avatar_place_holder) + + val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" + symbolTv.text = displaySymbol + + sideTv.text = if (position.side.lowercase() == "long") { + context.getString(R.string.Long) + } else { + context.getString(R.string.Short) + } + sideTv.setTextColor( + if (position.side.lowercase() == "long") { + context.getColor(R.color.wallet_green) + } else { + context.getColor(R.color.wallet_red) + } + ) + + leverageTv.text = "${position.leverage}x" + + val quantity = position.quantity.toBigDecimalOrNull() + val quantityStr = if (quantity != null) { + String.format("%.4f", quantity) + } else { + position.quantity + } + quantityTv.text = "$quantityStr ${position.tokenSymbol ?: ""}" + + val pnl = position.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO + pnlTv.text = String.format("$%.2f", pnl.abs()) + pnlTv.setTextColor( + if (pnl >= BigDecimal.ZERO) { + context.getColor(R.color.wallet_green) + } else { + context.getColor(R.color.wallet_red) + } + ) + + val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + val closePrice = position.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceChange = if (entryPrice > BigDecimal.ZERO) { + ((closePrice - entryPrice) / entryPrice * BigDecimal(100)) + } else { + BigDecimal.ZERO + } + + priceChangeTv.text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange) + priceChangeTv.setTextColor( + if (priceChange >= BigDecimal.ZERO) { + context.getColor(R.color.wallet_green) + } else { + context.getColor(R.color.wallet_red) + } + ) + } + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PositionHistoryView, + newItem: PositionHistoryView + ): Boolean { + return oldItem.historyId == newItem.historyId + } + + override fun areContentsTheSame( + oldItem: PositionHistoryView, + newItem: PositionHistoryView + ): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index 5c2a7ab46a..cccb262777 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -21,11 +23,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal -import java.text.SimpleDateFormat -import java.util.Locale @Composable fun ClosedPositionItem(position: PositionHistoryView) { @@ -37,102 +38,110 @@ fun ClosedPositionItem(position: PositionHistoryView) { val isProfit = pnl >= BigDecimal.ZERO val pnlColor = if (isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) - val pnlText = "${if (isProfit) "+" else ""}$${String.format("%.2f", pnl)}" - - val entryPrice = try { - val price = BigDecimal(position.entryPrice) - String.format("%.4f", price) - } catch (e: Exception) { - position.entryPrice - } - - val closePrice = try { - val price = BigDecimal(position.closePrice) - String.format("%.4f", price) - } catch (e: Exception) { - position.closePrice - } - - val closedDate = try { - val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - val outputFormat = SimpleDateFormat("MMM dd, HH:mm", Locale.US) - val date = inputFormat.parse(position.closedAt) - date?.let { outputFormat.format(it) } ?: position.closedAt + + val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" + val quantity = try { + val qty = BigDecimal(position.quantity) + String.format("%.4f", qty) } catch (e: Exception) { - position.closedAt + position.quantity } - - val displaySymbol = position.marketSymbol ?: "Unknown" Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .padding(12.dp), + .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier.weight(1f) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = displaySymbol, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = position.side.uppercase(), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - color = if (position.side.lowercase() == "long") Color(0xFF4CAF50) else Color(0xFFF44336), - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .cardBackground( - if (position.side.lowercase() == "long") Color(0xFF4CAF50).copy(alpha = 0.1f) else Color(0xFFF44336).copy(alpha = 0.1f), - Color.Transparent - ) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) + CoilImage( + model = position.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val sideText = if (position.side.lowercase() == "long") { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + Text( + text = sideText, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (position.side.lowercase() == "long") Color(0xFF4CAF50) else Color(0xFFF44336) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = displaySymbol, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "${position.leverage}x", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .cardBackground( + MixinAppTheme.colors.backgroundWindow, + Color.Transparent + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${position.leverage}x", + text = "$quantity ${position.tokenSymbol ?: ""}", fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist, + color = MixinAppTheme.colors.textAssist ) } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.Entry_Close_Price, entryPrice, closePrice), - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = closedDate, - fontSize = 11.sp, - color = MixinAppTheme.colors.textAssist, - ) } Column( horizontalAlignment = Alignment.End ) { Text( - text = pnlText, - fontSize = 16.sp, + text = String.format("$%.2f", pnl.abs()), + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, - color = pnlColor, + color = pnlColor ) Spacer(modifier = Modifier.height(2.dp)) + val entryPrice = try { + BigDecimal(position.entryPrice) + } catch (e: Exception) { + BigDecimal.ZERO + } + val closePrice = try { + BigDecimal(position.closePrice) + } catch (e: Exception) { + BigDecimal.ZERO + } + val priceChange = if (entryPrice > BigDecimal.ZERO) { + ((closePrice - entryPrice) / entryPrice * BigDecimal(100)) + } else { + BigDecimal.ZERO + } Text( - text = "${position.quantity} ${displaySymbol}", + text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange), fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist, + color = if (priceChange >= BigDecimal.ZERO) Color(0xFF4CAF50) else Color(0xFFF44336) ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index ee04cae020..93fdd09763 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -51,6 +51,7 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground fun PerpetualContent( onShowTradingGuide: () -> Unit, onShowMarketList: (isLong: Boolean) -> Unit, + onShowAllClosedPositions: () -> Unit, ) { val walletId = Session.getAccountId()!! val context = LocalContext.current @@ -283,6 +284,9 @@ fun PerpetualContent( .wrapContentHeight() .clip(RoundedCornerShape(8.dp)) .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) + .clickable { + onShowAllClosedPositions() + } .padding(16.dp) ) { Row( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index 0a2e6ffc21..213c9607d9 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -377,7 +377,11 @@ class PerpetualViewModel @Inject constructor( val enrichedData = data.map { history -> val market = marketMap[history.productId] - history.copy(marketSymbol = market?.displaySymbol ?: history.productId) + history.apply { + displaySymbol = market?.displaySymbol + iconUrl = market?.iconUrl + tokenSymbol = market?.tokenSymbol + } } onSuccess(enrichedData) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt new file mode 100644 index 0000000000..cb0d4990e3 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt @@ -0,0 +1,56 @@ +package one.mixin.android.ui.home.web3.trade + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.getParcelableCompat +import one.mixin.android.extension.isNightMode +import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.ui.common.BaseFragment + +@AndroidEntryPoint +class PositionDetailFragment : BaseFragment() { + companion object { + const val TAG = "PositionDetailFragment" + private const val ARGS_POSITION = "args_position" + + fun newInstance(position: PositionHistoryView): PositionDetailFragment { + return PositionDetailFragment().apply { + arguments = Bundle().apply { + putParcelable(ARGS_POSITION, position) + } + } + } + } + + private val viewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val position = arguments?.getParcelableCompat(ARGS_POSITION, PositionHistoryView::class.java) + ?: throw IllegalArgumentException("Position is required") + + return ComposeView(inflater.context).apply { + setContent { + MixinAppTheme( + darkTheme = context.isNightMode(), + ) { + PositionDetailPage( + position = position, + pop = { + activity?.onBackPressedDispatcher?.onBackPressed() + } + ) + } + } + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt new file mode 100644 index 0000000000..8cd369c621 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt @@ -0,0 +1,269 @@ +package one.mixin.android.ui.home.web3.trade + +import PageScaffold +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.R +import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.ui.wallet.alert.components.cardBackground +import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.util.Locale + +@Composable +fun PositionDetailPage( + position: PositionHistoryView, + pop: () -> Unit, +) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + fun formatDate(dateStr: String): String { + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + val date = inputFormat.parse(dateStr) + date?.let { dateFormat.format(it) } ?: dateStr + } catch (e: Exception) { + dateStr + } + } + + val pnl = try { + BigDecimal(position.realizedPnl) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isProfit = pnl >= BigDecimal.ZERO + val pnlColor = if (isProfit) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + + PageScaffold( + title = stringResource(R.string.Position_Details), + verticalScrollable = false, + pop = pop + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .cardBackground( + MixinAppTheme.colors.background, + MixinAppTheme.colors.borderColor + ) + ) { + Spacer(modifier = Modifier.height(30.dp)) + + CoilImage( + model = position.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(70.dp) + .clip(CircleShape) + .align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = String.format("$%.2f", pnl.abs()), + fontSize = 24.sp, + fontWeight = FontWeight.W500, + color = pnlColor, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(pnlColor.copy(alpha = 0.2f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + .align(Alignment.CenterHorizontally) + ) { + val sideText = if (position.side.lowercase() == "long") { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + Text( + text = "$sideText ${position.leverage}x", + color = pnlColor, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MixinAppTheme.colors.backgroundWindow), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.Trade_Again), + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + modifier = Modifier + .weight(1f) + .padding(vertical = 10.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Box( + modifier = Modifier + .width(2.dp) + .height(24.dp) + .background(Color(0x0D000000)) + ) + Text( + text = stringResource(R.string.Share), + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + modifier = Modifier + .weight(1f) + .padding(vertical = 10.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(30.dp)) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .wrapContentHeight() + .cardBackground( + MixinAppTheme.colors.background, + MixinAppTheme.colors.borderColor + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + PositionDetailItem( + label = stringResource(R.string.Perpetual).uppercase(), + value = position.displaySymbol ?: position.tokenSymbol ?: "Unknown", + icon = position.iconUrl + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Order_Value).uppercase(), + value = "${position.quantity.toBigDecimalOrNull()?.let { String.format("%.4f", it) } ?: position.quantity} ${position.tokenSymbol ?: ""}" + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Entry_Price).uppercase(), + value = String.format("$%.2f", position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Close_Price).uppercase(), + value = String.format("$%.2f", position.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + one.mixin.android.ui.tip.wc.compose.ItemWalletContent( + title = stringResource(R.string.Wallet).uppercase(), + fontSize = 16.sp, + padding = 0.dp + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Close_Time).uppercase(), + value = formatDate(position.closedAt) + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + } + } +} + +@Composable +private fun PositionDetailItem( + label: String, + value: String, + icon: String? = null +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = label, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + + if (icon != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + CoilImage( + model = icon, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = value, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = MixinAppTheme.colors.textPrimary + ) + } + } else { + Text( + text = value, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = MixinAppTheme.colors.textPrimary + ) + } + } +} 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 84113cf86a..471e635d40 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 @@ -335,6 +335,9 @@ class TradeFragment : BaseFragment() { }, onShowMarketList = { isLong -> MarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, MarketListBottomSheetDialogFragment.TAG) + }, + onShowAllClosedPositions = { + navTo(AllClosedPositionsFragment.newInstance(), AllClosedPositionsFragment.TAG) } ) } 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 e0f2711b79..9590dac714 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 @@ -90,6 +90,7 @@ fun TradePage( onLimitOrderClick: (String) -> Unit, onShowTradingGuide: () -> Unit, onShowMarketList: (Boolean) -> Unit, + onShowAllClosedPositions: () -> Unit, ) { val context = LocalContext.current @@ -169,7 +170,8 @@ fun TradePage( tabs += TabItem(title = stringResource(R.string.Perpetual)) { PerpetualContent( onShowTradingGuide = onShowTradingGuide, - onShowMarketList = onShowMarketList + onShowMarketList = onShowMarketList, + onShowAllClosedPositions = onShowAllClosedPositions ) } } diff --git a/app/src/main/res/drawable/bg_round_gray_4dp.xml b/app/src/main/res/drawable/bg_round_gray_4dp.xml new file mode 100644 index 0000000000..0973709a1f --- /dev/null +++ b/app/src/main/res/drawable/bg_round_gray_4dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_all_closed_positions.xml b/app/src/main/res/layout/fragment_all_closed_positions.xml new file mode 100644 index 0000000000..0ade0cbeae --- /dev/null +++ b/app/src/main/res/layout/fragment_all_closed_positions.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_closed_position_list.xml b/app/src/main/res/layout/item_closed_position_list.xml new file mode 100644 index 0000000000..82e02f11d3 --- /dev/null +++ b/app/src/main/res/layout/item_closed_position_list.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 41618be310..c4b9856a41 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2282,7 +2282,18 @@ 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 做空 + 方向 开仓价格 + 平仓价格 + 数量 + 持仓详情 + 持仓概要 + 持仓信息 + 已实现盈亏 + 价格变化 + 时间信息 + 开仓时间 + 平仓时间 逐仓 Mixin Futures 确认开仓 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3af94c1b80..602ad767ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2341,7 +2341,18 @@ Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) Long Short + Side Entry Price + Close Price + Quantity + Position Details + Position Summary + Position Info + Realized PnL + Price Change + Time Info + Open Time + Close Time Isolated Mixin Futures Wallet not found From de8b90de99a3d7e1656b751ae973d7034bf7fa8d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 2 Mar 2026 11:23:04 +0800 Subject: [PATCH 014/105] Closed position, open position --- .../one.mixin.android.db.PerpsDatabase/1.json | 162 +++++++++++--- .../android/api/response/perps/PerpsExt.kt | 44 ++++ .../android/api/response/perps/PerpsMarket.kt | 2 +- .../{PositionView.kt => PerpsPosition.kt} | 44 ++-- ...HistoryView.kt => PerpsPositionHistory.kt} | 27 ++- .../perps/PerpsPositionHistoryItem.kt | 57 +++++ .../api/response/perps/PerpsPositionItem.kt | 81 +++++++ .../mixin/android/api/service/RouteService.kt | 4 +- .../one/mixin/android/db/PerpsDatabase.kt | 7 +- .../mixin/android/db/perps/PerpsMarketDao.kt | 10 +- .../android/db/perps/PerpsPositionDao.kt | 33 ++- .../db/perps/PerpsPositionHistoryDao.kt | 39 ++++ .../java/one/mixin/android/di/PerpsModule.kt | 7 + .../web3/trade/AllClosedPositionsFragment.kt | 86 ------- .../home/web3/trade/AllPositionsFragment.kt | 157 +++++++++++++ .../home/web3/trade/ClosedPositionAdapter.kt | 22 +- .../ui/home/web3/trade/ClosedPositionItem.kt | 4 +- .../ui/home/web3/trade/MarketDetailPage.kt | 6 +- .../ui/home/web3/trade/OpenPositionAdapter.kt | 119 ++++++++++ .../ui/home/web3/trade/PerpetualContent.kt | 7 +- .../ui/home/web3/trade/PerpetualViewModel.kt | 82 +++++-- .../PerpsCloseBottomSheetDialogFragment.kt | 20 +- .../home/web3/trade/PositionDetailFragment.kt | 39 +++- .../ui/home/web3/trade/PositionDetailPage.kt | 211 ++++++++++++++++-- .../web3/trade/TotalPositionValueAdapter.kt | 57 +++++ .../ui/home/web3/trade/TradeFragment.kt | 2 +- .../android/ui/home/web3/trade/TradePage.kt | 7 +- .../layout/fragment_all_closed_positions.xml | 46 +++- .../res/layout/item_total_position_value.xml | 29 +++ app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + 31 files changed, 1182 insertions(+), 232 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt rename app/src/main/java/one/mixin/android/api/response/perps/{PositionView.kt => PerpsPosition.kt} (69%) rename app/src/main/java/one/mixin/android/api/response/perps/{PositionHistoryView.kt => PerpsPositionHistory.kt} (56%) create mode 100644 app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt create mode 100644 app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt create mode 100644 app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt delete mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/AllClosedPositionsFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt create mode 100644 app/src/main/res/layout/item_total_position_value.xml diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index 68511a4055..4820611bc6 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "1ca362aa376fdb9dc505bf1d49fae4b9", + "identityHash": "28441fcf48069a0899c98e75eaacf4c9", "entities": [ { - "tableName": "perps_positions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `settle_asset_id` TEXT NOT NULL, `bot_id` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, PRIMARY KEY(`position_id`))", + "tableName": "positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `settle_asset_id` TEXT, `bot_id` TEXT, `margin` TEXT, `state` TEXT, `mark_price` TEXT, `unrealized_pnl` TEXT, `roe` TEXT, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, `display_symbol` TEXT, `icon_url` TEXT, `token_symbol` TEXT, PRIMARY KEY(`position_id`))", "fields": [ { "fieldPath": "positionId", @@ -33,58 +33,51 @@ "notNull": true }, { - "fieldPath": "settleAssetId", - "columnName": "settle_asset_id", + "fieldPath": "entryPrice", + "columnName": "entry_price", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "botId", - "columnName": "bot_id", - "affinity": "TEXT", + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", "notNull": true }, { - "fieldPath": "entryPrice", - "columnName": "entry_price", - "affinity": "TEXT", - "notNull": true + "fieldPath": "settleAssetId", + "columnName": "settle_asset_id", + "affinity": "TEXT" }, { - "fieldPath": "margin", - "columnName": "margin", - "affinity": "TEXT", - "notNull": true + "fieldPath": "botId", + "columnName": "bot_id", + "affinity": "TEXT" }, { - "fieldPath": "leverage", - "columnName": "leverage", - "affinity": "INTEGER", - "notNull": true + "fieldPath": "margin", + "columnName": "margin", + "affinity": "TEXT" }, { "fieldPath": "state", "columnName": "state", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "markPrice", "columnName": "mark_price", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "unrealizedPnl", "columnName": "unrealized_pnl", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "roe", "columnName": "roe", - "affinity": "TEXT", - "notNull": true + "affinity": "TEXT" }, { "fieldPath": "walletId", @@ -100,6 +93,21 @@ "fieldPath": "updatedAt", "columnName": "updated_at", "affinity": "TEXT" + }, + { + "fieldPath": "displaySymbol", + "columnName": "display_symbol", + "affinity": "TEXT" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "token_symbol", + "affinity": "TEXT" } ], "primaryKey": { @@ -110,7 +118,101 @@ } }, { - "tableName": "perps_markets", + "tableName": "position_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`history_id` TEXT NOT NULL, `position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `market_symbol` TEXT, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `close_price` TEXT NOT NULL, `realized_pnl` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `margin_method` TEXT, `open_at` TEXT NOT NULL, `closed_at` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, PRIMARY KEY(`history_id`))", + "fields": [ + { + "fieldPath": "historyId", + "columnName": "history_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionId", + "columnName": "position_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "product_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketSymbol", + "columnName": "market_symbol", + "affinity": "TEXT" + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "entryPrice", + "columnName": "entry_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "closePrice", + "columnName": "close_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "realizedPnl", + "columnName": "realized_pnl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marginMethod", + "columnName": "margin_method", + "affinity": "TEXT" + }, + { + "fieldPath": "openAt", + "columnName": "open_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "closedAt", + "columnName": "closed_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "wallet_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "history_id" + ] + } + }, + { + "tableName": "markets", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `market` TEXT NOT NULL, `symbol` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `maker_fee` TEXT NOT NULL, `taker_fee` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `quantity_increment` TEXT NOT NULL, `price_increment` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `change` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", "fields": [ { @@ -244,7 +346,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1ca362aa376fdb9dc505bf1d49fae4b9')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '28441fcf48069a0899c98e75eaacf4c9')" ] } } \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt new file mode 100644 index 0000000000..283def9a90 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt @@ -0,0 +1,44 @@ +package one.mixin.android.api.response.perps + +fun PerpsPositionItem.toPosition(): PerpsPosition { + return PerpsPosition( + positionId = positionId, + productId = productId, + side = side, + quantity = quantity, + entryPrice = entryPrice, + leverage = leverage, + settleAssetId = settleAssetId, + botId = botId, + margin = margin, + state = state, + markPrice = markPrice, + unrealizedPnl = unrealizedPnl, + roe = roe, + walletId = walletId, + createdAt = createdAt, + updatedAt = updatedAt, + displaySymbol = displaySymbol, + iconUrl = iconUrl, + tokenSymbol = tokenSymbol + ) +} + +fun PerpsPositionHistoryItem.toPositionHistory(): PerpsPositionHistory { + return PerpsPositionHistory( + historyId = historyId, + positionId = positionId, + productId = productId, + marketSymbol = marketSymbol, + side = side, + quantity = quantity, + entryPrice = entryPrice, + closePrice = closePrice, + realizedPnl = realizedPnl, + leverage = leverage, + marginMethod = marginMethod, + openAt = openAt, + closedAt = closedAt, + walletId = walletId + ) +} diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index d614887b50..f36edfda08 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -5,7 +5,7 @@ import androidx.room.Entity import androidx.room.PrimaryKey import com.google.gson.annotations.SerializedName -@Entity(tableName = "perps_markets") +@Entity(tableName = "markets") data class PerpsMarket( @PrimaryKey @SerializedName("market_id") diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt similarity index 69% rename from app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt rename to app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt index eccc54027b..32ca2b4c62 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PositionView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt @@ -1,12 +1,15 @@ package one.mixin.android.api.response.perps +import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize -@Entity(tableName = "perps_positions") -data class PerpsPosition( +@Parcelize +@Entity(tableName = "positions") +data class PerpsPosition( @PrimaryKey @SerializedName("position_id") @ColumnInfo(name = "position_id") @@ -20,33 +23,33 @@ data class PerpsPosition( @SerializedName("quantity") @ColumnInfo(name = "quantity") val quantity: String, - @SerializedName("settle_asset_id") - @ColumnInfo(name = "settle_asset_id") - val settleAssetId: String, - @SerializedName("bot_id") - @ColumnInfo(name = "bot_id") - val botId: String, @SerializedName("entry_price") @ColumnInfo(name = "entry_price") val entryPrice: String, - @SerializedName("margin") - @ColumnInfo(name = "margin") - val margin: String, @SerializedName("leverage") @ColumnInfo(name = "leverage") val leverage: Int, + @SerializedName("settle_asset_id") + @ColumnInfo(name = "settle_asset_id") + val settleAssetId: String? = null, + @SerializedName("bot_id") + @ColumnInfo(name = "bot_id") + val botId: String? = null, + @SerializedName("margin") + @ColumnInfo(name = "margin") + val margin: String? = null, @SerializedName("state") @ColumnInfo(name = "state") - val state: String, + val state: String? = null, @SerializedName("mark_price") @ColumnInfo(name = "mark_price") - val markPrice: String, + val markPrice: String? = null, @SerializedName("unrealized_pnl") @ColumnInfo(name = "unrealized_pnl") - val unrealizedPnl: String, + val unrealizedPnl: String? = null, @SerializedName("roe") @ColumnInfo(name = "roe") - val roe: String, + val roe: String? = null, @ColumnInfo(name = "wallet_id") val walletId: String? = null, @SerializedName("created_at") @@ -54,6 +57,11 @@ data class PerpsPosition( val createdAt: String? = null, @SerializedName("updated_at") @ColumnInfo(name = "updated_at") - val updatedAt: String? = null -) - + val updatedAt: String? = null, + @ColumnInfo(name = "display_symbol") + var displaySymbol: String? = null, + @ColumnInfo(name = "icon_url") + var iconUrl: String? = null, + @ColumnInfo(name = "token_symbol") + var tokenSymbol: String? = null +) : Parcelable diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt similarity index 56% rename from app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt rename to app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt index b5c2b0023b..b2719749df 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PositionHistoryView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt @@ -1,38 +1,55 @@ package one.mixin.android.api.response.perps import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize @Parcelize -data class PositionHistoryView( +@Entity(tableName = "position_history") +data class PerpsPositionHistory( + @PrimaryKey @SerializedName("history_id") + @ColumnInfo(name = "history_id") val historyId: String, @SerializedName("position_id") + @ColumnInfo(name = "position_id") val positionId: String, @SerializedName("product_id") + @ColumnInfo(name = "product_id") val productId: String, @SerializedName("market_symbol") + @ColumnInfo(name = "market_symbol") val marketSymbol: String? = null, @SerializedName("side") + @ColumnInfo(name = "side") val side: String, @SerializedName("quantity") + @ColumnInfo(name = "quantity") val quantity: String, @SerializedName("entry_price") + @ColumnInfo(name = "entry_price") val entryPrice: String, @SerializedName("close_price") + @ColumnInfo(name = "close_price") val closePrice: String, @SerializedName("realized_pnl") + @ColumnInfo(name = "realized_pnl") val realizedPnl: String, @SerializedName("leverage") + @ColumnInfo(name = "leverage") val leverage: Int, @SerializedName("margin_method") + @ColumnInfo(name = "margin_method") val marginMethod: String? = null, @SerializedName("open_at") + @ColumnInfo(name = "open_at") val openAt: String, @SerializedName("closed_at") + @ColumnInfo(name = "closed_at") val closedAt: String, - var displaySymbol: String? = null, - var iconUrl: String? = null, - var tokenSymbol: String? = null -) : Parcelable \ No newline at end of file + @ColumnInfo(name = "wallet_id") + val walletId: String = "" +) : Parcelable diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt new file mode 100644 index 0000000000..1894a8a979 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt @@ -0,0 +1,57 @@ +package one.mixin.android.api.response.perps + +import android.os.Parcelable +import androidx.room.ColumnInfo +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PerpsPositionHistoryItem( + @SerializedName("history_id") + @ColumnInfo(name = "history_id") + val historyId: String, + @SerializedName("position_id") + @ColumnInfo(name = "position_id") + val positionId: String, + @SerializedName("product_id") + @ColumnInfo(name = "product_id") + val productId: String, + @SerializedName("market_symbol") + @ColumnInfo(name = "market_symbol") + val marketSymbol: String? = null, + @SerializedName("side") + @ColumnInfo(name = "side") + val side: String, + @SerializedName("quantity") + @ColumnInfo(name = "quantity") + val quantity: String, + @SerializedName("entry_price") + @ColumnInfo(name = "entry_price") + val entryPrice: String, + @SerializedName("close_price") + @ColumnInfo(name = "close_price") + val closePrice: String, + @SerializedName("realized_pnl") + @ColumnInfo(name = "realized_pnl") + val realizedPnl: String, + @SerializedName("leverage") + @ColumnInfo(name = "leverage") + val leverage: Int, + @SerializedName("margin_method") + @ColumnInfo(name = "margin_method") + val marginMethod: String? = null, + @SerializedName("open_at") + @ColumnInfo(name = "open_at") + val openAt: String, + @SerializedName("closed_at") + @ColumnInfo(name = "closed_at") + val closedAt: String, + @ColumnInfo(name = "wallet_id") + val walletId: String, + @ColumnInfo(name = "display_symbol") + val displaySymbol: String? = null, + @ColumnInfo(name = "icon_url") + val iconUrl: String? = null, + @ColumnInfo(name = "token_symbol") + val tokenSymbol: String? = null +) : Parcelable diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt new file mode 100644 index 0000000000..f158e91140 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt @@ -0,0 +1,81 @@ +package one.mixin.android.api.response.perps + +import android.os.Parcelable +import androidx.room.ColumnInfo +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PerpsPositionItem( + @SerializedName("position_id") + @ColumnInfo(name = "position_id") + val positionId: String, + @SerializedName("product_id") + @ColumnInfo(name = "product_id") + val productId: String, + @SerializedName("side") + @ColumnInfo(name = "side") + val side: String, + @SerializedName("quantity") + @ColumnInfo(name = "quantity") + val quantity: String, + @SerializedName("entry_price") + @ColumnInfo(name = "entry_price") + val entryPrice: String, + @SerializedName("leverage") + @ColumnInfo(name = "leverage") + val leverage: Int, + @SerializedName("settle_asset_id") + @ColumnInfo(name = "settle_asset_id") + val settleAssetId: String? = null, + @SerializedName("bot_id") + @ColumnInfo(name = "bot_id") + val botId: String? = null, + @SerializedName("margin") + @ColumnInfo(name = "margin") + val margin: String? = null, + @SerializedName("state") + @ColumnInfo(name = "state") + val state: String? = null, + @SerializedName("mark_price") + @ColumnInfo(name = "mark_price") + val markPrice: String? = null, + @SerializedName("unrealized_pnl") + @ColumnInfo(name = "unrealized_pnl") + val unrealizedPnl: String? = null, + @SerializedName("roe") + @ColumnInfo(name = "roe") + val roe: String? = null, + @ColumnInfo(name = "wallet_id") + val walletId: String? = null, + @SerializedName("created_at") + @ColumnInfo(name = "created_at") + val createdAt: String? = null, + @SerializedName("updated_at") + @ColumnInfo(name = "updated_at") + val updatedAt: String? = null, + @ColumnInfo(name = "display_symbol") + val displaySymbol: String? = null, + @ColumnInfo(name = "icon_url") + val iconUrl: String? = null, + @ColumnInfo(name = "token_symbol") + val tokenSymbol: String? = null, +) : Parcelable + +fun PerpsPositionItem.toPosotion(): PerpsPosition { + return PerpsPosition( + positionId, + productId, + side, + quantity, + entryPrice, + leverage, + settleAssetId, + botId, + margin, + state, + markPrice, + unrealizedPnl, + roe, walletId, createdAt, updatedAt, displaySymbol, iconUrl, tokenSymbol, + ) +} \ No newline at end of file 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 f6dc2199a4..744b220b83 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 @@ -64,7 +64,7 @@ import one.mixin.android.api.request.perps.CloseOrderResponse import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.perps.CandleView import one.mixin.android.api.response.perps.PerpsPosition -import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.api.response.perps.PerpsPositionHistory import retrofit2.http.Query @@ -396,5 +396,5 @@ interface RouteService { @Query("offset") offset: String? = null, @Query("limit") limit: Int = 100, @Query("wallet_id") walletId: String - ): MixinResponse> + ): MixinResponse> } diff --git a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt index 164ec34070..54516b7be6 100644 --- a/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt +++ b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt @@ -9,8 +9,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import one.mixin.android.Constants import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.api.response.perps.PerpsPositionHistory import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.db.perps.PerpsPositionDao +import one.mixin.android.db.perps.PerpsPositionHistoryDao import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.util.SINGLE_DB_EXECUTOR import one.mixin.android.util.database.dbDir @@ -22,6 +24,7 @@ import kotlin.math.min @Database( entities = [ PerpsPosition::class, + PerpsPositionHistory::class, PerpsMarket::class, ], version = 1, @@ -58,7 +61,8 @@ abstract class PerpsDatabase : RoomDatabase() { db.execSQL("PRAGMA synchronous = NORMAL") } }, - ).enableMultiInstanceInvalidation() + ).fallbackToDestructiveMigration() + .enableMultiInstanceInvalidation() .setQueryExecutor( Executors.newFixedThreadPool( max(2, min(Runtime.getRuntime().availableProcessors() - 1, 4)), @@ -73,6 +77,7 @@ abstract class PerpsDatabase : RoomDatabase() { } abstract fun perpsPositionDao(): PerpsPositionDao + abstract fun perpsPositionHistoryDao(): PerpsPositionHistoryDao abstract fun perpsMarketDao(): PerpsMarketDao override fun close() { diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt index 4aa0b222af..1f35ca73b5 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt @@ -15,18 +15,18 @@ interface PerpsMarketDao : BaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(markets: List) - @Query("SELECT * FROM perps_markets ORDER BY CAST(volume AS REAL) DESC") + @Query("SELECT * FROM markets ORDER BY CAST(volume AS REAL) DESC") suspend fun getAllMarkets(): List - @Query("SELECT * FROM perps_markets WHERE market_id = :marketId") + @Query("SELECT * FROM markets WHERE market_id = :marketId") suspend fun getMarket(marketId: String): PerpsMarket? - @Query("SELECT * FROM perps_markets WHERE symbol LIKE '%' || :query || '%' ORDER BY CAST(volume AS REAL) DESC") + @Query("SELECT * FROM markets WHERE symbol LIKE '%' || :query || '%' ORDER BY CAST(volume AS REAL) DESC") suspend fun searchMarkets(query: String): List - @Query("DELETE FROM perps_markets") + @Query("DELETE FROM markets") suspend fun deleteAll() - @Query("SELECT COUNT(*) FROM perps_markets") + @Query("SELECT COUNT(*) FROM markets") suspend fun getCount(): Int } diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index c3e5a2c62d..eeb17ed062 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.db.BaseDao @Dao @@ -15,21 +16,29 @@ interface PerpsPositionDao : BaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(positions: List) - @Query("SELECT * FROM perps_positions WHERE wallet_id = :walletId AND state = 'open' ORDER BY created_at DESC") - suspend fun getOpenPositions(walletId: String): List - - @Query("SELECT * FROM perps_positions WHERE wallet_id = :walletId ORDER BY created_at DESC LIMIT :limit") - suspend fun getPositionHistory(walletId: String, limit: Int = 100): List - - @Query("SELECT * FROM perps_positions WHERE position_id = :positionId") - suspend fun getPosition(positionId: String): PerpsPosition? - - @Query("UPDATE perps_positions SET state = :status, updated_at = :updatedAt WHERE position_id = :positionId") + @Query(""" + SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol + FROM positions p + LEFT JOIN markets m ON m.market_id = p.product_id + WHERE p.wallet_id = :walletId AND p.state = 'open' + ORDER BY p.created_at DESC + """) + suspend fun getOpenPositions(walletId: String): List + + @Query(""" + SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol + FROM positions p + LEFT JOIN markets m ON m.market_id = p.product_id + WHERE p.position_id = :positionId + """) + suspend fun getPosition(positionId: String): PerpsPositionItem? + + @Query("UPDATE positions SET state = :status, updated_at = :updatedAt WHERE position_id = :positionId") suspend fun updateStatus(positionId: String, status: String, updatedAt: String) - @Query("DELETE FROM perps_positions WHERE wallet_id = :walletId") + @Query("DELETE FROM positions WHERE wallet_id = :walletId") suspend fun deleteByWallet(walletId: String) - @Query("SELECT SUM(CAST(unrealized_pnl AS REAL)) FROM perps_positions WHERE wallet_id = :walletId AND state = 'open'") + @Query("SELECT SUM(CAST(unrealized_pnl AS REAL)) FROM positions WHERE wallet_id = :walletId AND state = 'open'") suspend fun getTotalUnrealizedPnl(walletId: String): Double? } diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt new file mode 100644 index 0000000000..a6b86bb2bf --- /dev/null +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -0,0 +1,39 @@ +package one.mixin.android.db.perps + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import one.mixin.android.api.response.perps.PerpsPositionHistory +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.db.BaseDao + +@Dao +interface PerpsPositionHistoryDao : BaseDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(history: PerpsPositionHistory) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(histories: List) + + @Query(""" + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + FROM position_history h + LEFT JOIN markets m ON m.market_id = h.product_id + WHERE h.wallet_id = :walletId + ORDER BY h.closed_at DESC + LIMIT :limit + """) + suspend fun getHistories(walletId: String, limit: Int): List + + @Query(""" + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + FROM position_history h + LEFT JOIN markets m ON m.market_id = h.product_id + WHERE h.history_id = :historyId + """) + suspend fun getHistory(historyId: String): PerpsPositionHistoryItem? + + @Query("DELETE FROM position_history WHERE wallet_id = :walletId") + suspend fun deleteByWallet(walletId: String) +} diff --git a/app/src/main/java/one/mixin/android/di/PerpsModule.kt b/app/src/main/java/one/mixin/android/di/PerpsModule.kt index cb4ff35fae..7cb7528f2c 100644 --- a/app/src/main/java/one/mixin/android/di/PerpsModule.kt +++ b/app/src/main/java/one/mixin/android/di/PerpsModule.kt @@ -9,6 +9,7 @@ import dagger.hilt.components.SingletonComponent import one.mixin.android.db.PerpsDatabase import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.db.perps.PerpsPositionDao +import one.mixin.android.db.perps.PerpsPositionHistoryDao import javax.inject.Singleton @Module @@ -27,6 +28,12 @@ object PerpsModule { return database.perpsPositionDao() } + @Provides + @Singleton + fun providePerpsPositionHistoryDao(database: PerpsDatabase): PerpsPositionHistoryDao { + return database.perpsPositionHistoryDao() + } + @Provides @Singleton fun providePerpsMarketDao(database: PerpsDatabase): PerpsMarketDao { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllClosedPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllClosedPositionsFragment.kt deleted file mode 100644 index 655e1c16dd..0000000000 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllClosedPositionsFragment.kt +++ /dev/null @@ -1,86 +0,0 @@ -package one.mixin.android.ui.home.web3.trade - -import android.os.Bundle -import android.view.View -import androidx.core.view.isVisible -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import one.mixin.android.R -import one.mixin.android.api.response.perps.PositionHistoryView -import one.mixin.android.databinding.FragmentAllClosedPositionsBinding -import one.mixin.android.session.Session -import one.mixin.android.ui.common.BaseFragment -import one.mixin.android.util.viewBinding - -@AndroidEntryPoint -class AllClosedPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions) { - - companion object { - const val TAG = "AllClosedPositionsFragment" - - fun newInstance() = AllClosedPositionsFragment() - } - - private val binding by viewBinding(FragmentAllClosedPositionsBinding::bind) - private val viewModel by viewModels() - - private val adapter by lazy { - ClosedPositionAdapter { position -> - activity?.supportFragmentManager?.let { fm -> - fm.beginTransaction() - .add(android.R.id.content, PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) - .addToBackStack(null) - .commit() - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.apply { - titleView.leftIb.setOnClickListener { - activity?.onBackPressedDispatcher?.onBackPressed() - } - titleView.setSubTitle(getString(R.string.Closed_Positions), "") - - positionsRv.layoutManager = LinearLayoutManager(requireContext()) - positionsRv.adapter = adapter - } - - loadClosedPositions() - } - - private fun loadClosedPositions() { - val walletId = Session.getAccountId() ?: return - - binding.progressBar.isVisible = true - binding.emptyView.root.isVisible = false - - lifecycleScope.launch { - viewModel.loadPositionHistory( - walletId = walletId, - limit = 100, - onSuccess = { positions -> - binding.progressBar.isVisible = false - if (positions.isEmpty()) { - binding.emptyView.root.isVisible = true - binding.positionsRv.isVisible = false - } else { - binding.emptyView.root.isVisible = false - binding.positionsRv.isVisible = true - adapter.submitList(positions) - } - }, - onError = { - binding.progressBar.isVisible = false - binding.emptyView.root.isVisible = true - binding.positionsRv.isVisible = false - } - ) - } - } -} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt new file mode 100644 index 0000000000..c70a5f8c2b --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt @@ -0,0 +1,157 @@ +package one.mixin.android.ui.home.web3.trade + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.databinding.FragmentAllClosedPositionsBinding +import one.mixin.android.session.Session +import one.mixin.android.ui.common.BaseFragment +import one.mixin.android.util.viewBinding +import java.math.BigDecimal + +@AndroidEntryPoint +class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions) { + + companion object { + const val TAG = "AllPositionsFragment" + + fun newInstance() = AllPositionsFragment() + } + + private val binding by viewBinding(FragmentAllClosedPositionsBinding::bind) + private val viewModel by viewModels() + private val totalValueAdapter by lazy { TotalPositionValueAdapter() } + + private val openPositionAdapter by lazy { + OpenPositionAdapter { position -> + activity?.supportFragmentManager?.let { fm -> + fm.beginTransaction() + .add(android.R.id.content, PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) + .addToBackStack(null) + .commit() + } + } + } + + private val closedPositionAdapter by lazy { + ClosedPositionAdapter { position -> + activity?.supportFragmentManager?.let { fm -> + fm.beginTransaction() + .add(android.R.id.content, PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) + .addToBackStack(null) + .commit() + } + } + } + + private enum class PositionTab { + OPEN, + CLOSED + } + + private var currentTab: PositionTab = PositionTab.CLOSED + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.apply { + titleView.leftIb.setOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } + titleView.setSubTitle(getString(R.string.Closed_Positions), "") + + positionsRv.layoutManager = LinearLayoutManager(requireContext()) + + radioGroup.setOnCheckedChangeListener { _, checkedId -> + currentTab = if (checkedId == R.id.radio_open) { + PositionTab.OPEN + } else { + PositionTab.CLOSED + } + loadPositions() + } + + radioClosed.isChecked = true + loadPositions() + } + } + + private fun loadPositions() { + if (currentTab == PositionTab.OPEN) { + binding.titleView.setSubTitle(getString(R.string.Open_Positions_Simple), "") + binding.positionsRv.adapter = ConcatAdapter(totalValueAdapter, openPositionAdapter) + loadOpenPositions() + } else { + binding.titleView.setSubTitle(getString(R.string.Closed_Positions), "") + binding.positionsRv.adapter = ConcatAdapter(totalValueAdapter, closedPositionAdapter) + loadClosedPositions() + } + } + + private fun loadOpenPositions() { + val walletId = Session.getAccountId() ?: return + + binding.progressBar.isVisible = true + binding.emptyView.root.isVisible = false + + lifecycleScope.launch { + val positions = viewModel.getOpenPositionsFromDb(walletId) + binding.progressBar.isVisible = false + if (positions.isEmpty()) { + openPositionAdapter.submitList(emptyList()) + binding.emptyView.infoTv.text = getString(R.string.No_Positions) + binding.emptyView.root.isVisible = true + totalValueAdapter.submitTotal(BigDecimal.ZERO) + } else { + binding.emptyView.root.isVisible = false + openPositionAdapter.submitList(positions) + totalValueAdapter.submitTotal(sumOpenPnl(positions)) + } + } + } + + private fun loadClosedPositions() { + val walletId = Session.getAccountId() ?: return + + binding.progressBar.isVisible = true + binding.emptyView.root.isVisible = false + + lifecycleScope.launch { + val positions = viewModel.getClosedPositionsFromDb(walletId, 100) + binding.progressBar.isVisible = false + if (positions.isEmpty()) { + closedPositionAdapter.submitList(emptyList()) + binding.emptyView.infoTv.text = getString(R.string.No_Closed_Positions) + binding.emptyView.root.isVisible = true + totalValueAdapter.submitTotal(BigDecimal.ZERO) + } else { + binding.emptyView.root.isVisible = false + closedPositionAdapter.submitList(positions) + totalValueAdapter.submitTotal(sumClosedPnl(positions)) + } + } + } + + private fun sumOpenPnl(positions: List): BigDecimal { + return positions.fold(BigDecimal.ZERO) { total, position -> + val pnl = (position.unrealizedPnl ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO + total + pnl + } + } + + private fun sumClosedPnl(positions: List): BigDecimal { + return positions.fold(BigDecimal.ZERO) { total, position -> + val pnl = position.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO + total + pnl + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt index 29d1245083..6cafc303e7 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt @@ -6,14 +6,14 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import one.mixin.android.R -import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.databinding.ItemClosedPositionListBinding import one.mixin.android.extension.loadImage import java.math.BigDecimal class ClosedPositionAdapter( - private val onItemClick: ((PositionHistoryView) -> Unit)? = null -) : ListAdapter(DiffCallback()) { + private val onItemClick: ((PerpsPositionHistoryItem) -> Unit)? = null +) : ListAdapter(DiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return ViewHolder( @@ -32,10 +32,10 @@ class ClosedPositionAdapter( class ViewHolder( private val binding: ItemClosedPositionListBinding, - private val onItemClick: ((PositionHistoryView) -> Unit)? + private val onItemClick: ((PerpsPositionHistoryItem) -> Unit)? ) : RecyclerView.ViewHolder(binding.root) { - fun bind(position: PositionHistoryView) { + fun bind(position: PerpsPositionHistoryItem) { binding.apply { val context = binding.root.context @@ -71,7 +71,7 @@ class ClosedPositionAdapter( } quantityTv.text = "$quantityStr ${position.tokenSymbol ?: ""}" - val pnl = position.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO + val pnl = (position.realizedPnl).toBigDecimalOrNull() ?: BigDecimal.ZERO pnlTv.text = String.format("$%.2f", pnl.abs()) pnlTv.setTextColor( if (pnl >= BigDecimal.ZERO) { @@ -101,17 +101,17 @@ class ClosedPositionAdapter( } } - private class DiffCallback : DiffUtil.ItemCallback() { + private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: PositionHistoryView, - newItem: PositionHistoryView + oldItem: PerpsPositionHistoryItem, + newItem: PerpsPositionHistoryItem ): Boolean { return oldItem.historyId == newItem.historyId } override fun areContentsTheSame( - oldItem: PositionHistoryView, - newItem: PositionHistoryView + oldItem: PerpsPositionHistoryItem, + newItem: PerpsPositionHistoryItem ): Boolean { return oldItem == newItem } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index cccb262777..dc0b217c08 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -22,14 +22,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R -import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal @Composable -fun ClosedPositionItem(position: PositionHistoryView) { +fun ClosedPositionItem(position: PerpsPositionHistoryItem) { val pnl = try { BigDecimal(position.realizedPnl) } catch (e: Exception) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt index 176d86a064..0ae8b691bd 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -48,6 +48,8 @@ import one.mixin.android.Constants import one.mixin.android.R 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 @@ -66,7 +68,7 @@ fun MarketDetailPage( var market by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var selectedTimeFrame by remember { mutableIntStateOf(0) } - var currentPosition by remember { mutableStateOf(null) } + var currentPosition by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val timeFrames = listOf("1h", "1d", "1w", "1M") @@ -156,7 +158,7 @@ fun MarketDetailPage( .height(48.dp), onClick = { val activity = context as? androidx.fragment.app.FragmentActivity ?: return@Button - val position = currentPosition ?: return@Button + val position = currentPosition?.toPosition() ?: return@Button PerpsCloseBottomSheetDialogFragment.newInstance( position = position, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt new file mode 100644 index 0000000000..591e4c9d99 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt @@ -0,0 +1,119 @@ +package one.mixin.android.ui.home.web3.trade + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.databinding.ItemClosedPositionListBinding +import one.mixin.android.extension.loadImage +import java.math.BigDecimal + +class OpenPositionAdapter( + private val onItemClick: ((PerpsPositionItem) -> Unit)? = null +) : ListAdapter(DiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + ItemClosedPositionListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + onItemClick + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + class ViewHolder( + private val binding: ItemClosedPositionListBinding, + private val onItemClick: ((PerpsPositionItem) -> Unit)? + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(position: PerpsPositionItem) { + binding.apply { + val context = binding.root.context + + root.setOnClickListener { + onItemClick?.invoke(position) + } + + iconIv.loadImage(position.iconUrl, R.drawable.ic_avatar_place_holder) + + val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" + symbolTv.text = displaySymbol + + sideTv.text = if (position.side.lowercase() == "long") { + context.getString(R.string.Long) + } else { + context.getString(R.string.Short) + } + sideTv.setTextColor( + if (position.side.lowercase() == "long") { + context.getColor(R.color.wallet_green) + } else { + context.getColor(R.color.wallet_red) + } + ) + + leverageTv.text = "${position.leverage}x" + + val quantity = position.quantity.toBigDecimalOrNull() + val quantityStr = if (quantity != null) { + String.format("%.4f", quantity) + } else { + position.quantity + } + quantityTv.text = "$quantityStr ${position.tokenSymbol ?: ""}" + + val pnl = (position.unrealizedPnl ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO + pnlTv.text = String.format("$%.2f", pnl.abs()) + pnlTv.setTextColor( + if (pnl >= BigDecimal.ZERO) { + context.getColor(R.color.wallet_green) + } else { + context.getColor(R.color.wallet_red) + } + ) + + val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + val markPrice = (position.markPrice ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceChange = if (entryPrice > BigDecimal.ZERO) { + ((markPrice - entryPrice) / entryPrice * BigDecimal(100)) + } else { + BigDecimal.ZERO + } + + priceChangeTv.text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange) + priceChangeTv.setTextColor( + if (priceChange >= BigDecimal.ZERO) { + context.getColor(R.color.wallet_green) + } else { + context.getColor(R.color.wallet_red) + } + ) + } + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PerpsPositionItem, + newItem: PerpsPositionItem + ): Boolean { + return oldItem.positionId == newItem.positionId + } + + override fun areContentsTheSame( + oldItem: PerpsPositionItem, + newItem: PerpsPositionItem + ): Boolean { + return oldItem == newItem + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index 93fdd09763..a09b851bf5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -42,7 +42,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket -import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground @@ -62,10 +62,13 @@ fun PerpetualContent( var errorMessage by remember { mutableStateOf(null) } var openPositionsCount by remember { mutableStateOf(0) } var totalPnl by remember { mutableStateOf(0.0) } - var closedPositions by remember { mutableStateOf>(emptyList()) } + var closedPositions by remember { mutableStateOf>(emptyList()) } var isLoadingHistory by remember { mutableStateOf(false) } LaunchedEffect(Unit) { + // Refresh positions from API + viewModel.refreshPositions(walletId) + viewModel.loadMarkets( onSuccess = { data -> markets = data diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index 213c9607d9..ac68bfabfc 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -12,8 +12,13 @@ import one.mixin.android.api.service.RouteService import one.mixin.android.api.request.perps.OpenOrderRequest import one.mixin.android.api.request.perps.OpenOrderResponse import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.api.response.perps.PerpsPositionHistory +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.db.perps.PerpsPositionDao import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.job.MixinJobManager +import one.mixin.android.job.RefreshPerpsPositionsJob import one.mixin.android.vo.safe.TokenItem import timber.log.Timber import javax.inject.Inject @@ -23,9 +28,15 @@ class PerpetualViewModel @Inject constructor( private val routeService: RouteService, private val tokenDao: one.mixin.android.db.TokenDao, private val perpsPositionDao: PerpsPositionDao, - private val perpsMarketDao: PerpsMarketDao + private val perpsPositionHistoryDao: one.mixin.android.db.perps.PerpsPositionHistoryDao, + private val perpsMarketDao: PerpsMarketDao, + private val jobManager: MixinJobManager ) : ViewModel() { + fun refreshPositions(walletId: String) { + jobManager.addJobInBackground(RefreshPerpsPositionsJob(walletId)) + } + fun loadMarkets( onSuccess: (List) -> Unit, onError: (String) -> Unit @@ -219,7 +230,7 @@ class PerpetualViewModel @Inject constructor( } } - fun getOpenPositions(walletId: String, onSuccess: (List) -> Unit) { + fun getOpenPositions(walletId: String, onSuccess: (List) -> Unit) { viewModelScope.launch { try { val positions = withContext(Dispatchers.IO) { @@ -233,6 +244,28 @@ class PerpetualViewModel @Inject constructor( } } + suspend fun getOpenPositionsFromDb(walletId: String): List { + return withContext(Dispatchers.IO) { + try { + perpsPositionDao.getOpenPositions(walletId) + } catch (e: Exception) { + Timber.e(e, "Error loading open positions from db") + emptyList() + } + } + } + + suspend fun getClosedPositionsFromDb(walletId: String, limit: Int): List { + return withContext(Dispatchers.IO) { + try { + perpsPositionHistoryDao.getHistories(walletId, limit) + } catch (e: Exception) { + Timber.e(e, "Error loading closed positions from db") + emptyList() + } + } + } + fun getTotalUnrealizedPnl(walletId: String, onSuccess: (Double) -> Unit) { viewModelScope.launch { try { @@ -247,7 +280,7 @@ class PerpetualViewModel @Inject constructor( } } - fun loadOpenPositions(walletId: String, onSuccess: (List) -> Unit) { + fun loadOpenPositions(walletId: String, onSuccess: (List) -> Unit) { viewModelScope.launch { try { val positions = withContext(Dispatchers.IO) { @@ -261,7 +294,7 @@ class PerpetualViewModel @Inject constructor( } } - fun getPositionByMarket(walletId: String, marketId: String, onSuccess: (PerpsPosition?) -> Unit) { + fun getPositionByMarket(walletId: String, marketId: String, onSuccess: (PerpsPositionItem?) -> Unit) { viewModelScope.launch { try { val positions = withContext(Dispatchers.IO) { @@ -353,11 +386,19 @@ class PerpetualViewModel @Inject constructor( walletId: String, limit: Int = 10, offset: String? = null, - onSuccess: (List) -> Unit, + onSuccess: (List) -> Unit, onError: (String) -> Unit ) { viewModelScope.launch { try { + val cachedHistories = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getHistories(walletId, limit) + } + + if (cachedHistories.isNotEmpty()) { + onSuccess(cachedHistories) + } + val response = withContext(Dispatchers.IO) { routeService.getPerpsPositionHistory( walletId = walletId, @@ -370,30 +411,33 @@ class PerpetualViewModel @Inject constructor( if (response.isSuccess && data != null) { Timber.d("Position history loaded: ${data.size} items") - val markets = withContext(Dispatchers.IO) { - perpsMarketDao.getAllMarkets() - } - val marketMap = markets.associateBy { it.marketId } + val histories = data.map { it.copy(walletId = walletId) } - val enrichedData = data.map { history -> - val market = marketMap[history.productId] - history.apply { - displaySymbol = market?.displaySymbol - iconUrl = market?.iconUrl - tokenSymbol = market?.tokenSymbol - } + withContext(Dispatchers.IO) { + perpsPositionHistoryDao.insertAll(histories) } - onSuccess(enrichedData) + val updatedHistories = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getHistories(walletId, limit) + } + onSuccess(updatedHistories) } else { val error = "Failed to load position history: ${response.errorDescription}" Timber.e(error) - onError(error) + if (cachedHistories.isEmpty()) { + onError(error) + } } } catch (e: Exception) { val error = "Error loading position history: ${e.message}" Timber.e(e, error) - onError(error) + + val cachedHistories = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getHistories(walletId, limit) + } + if (cachedHistories.isEmpty()) { + onError(error) + } } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt index 48201c996b..c8423a6302 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt @@ -177,9 +177,9 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen viewModel.loadPositionDetail( positionId = positionId, onSuccess = { position -> - latestMarkPrice = position.markPrice - latestUnrealizedPnl = position.unrealizedPnl - latestRoe = position.roe + latestMarkPrice = position.markPrice ?: "0" + latestUnrealizedPnl = position.unrealizedPnl ?: "0" + latestRoe = position.roe ?: "0" viewModel.loadMarketDetail( marketId = position.productId, @@ -191,13 +191,17 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) lifecycleScope.launch { - val asset = bottomViewModel.findAssetItemById(position.settleAssetId) - asset?.let { - settleAssetSymbol = it.symbol - settleAssetItem = it + position.settleAssetId?.let { assetId -> + val asset = bottomViewModel.findAssetItemById(assetId) + asset?.let { + settleAssetSymbol = it.symbol + settleAssetItem = it + } } - sender = bottomViewModel.refreshUser(position.botId) + position.botId?.let { botId -> + sender = bottomViewModel.refreshUser(botId) + } isLoading = false } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt index cb0d4990e3..7ae4f1bf9e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt @@ -10,7 +10,8 @@ import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.isNightMode -import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.ui.common.BaseFragment @AndroidEntryPoint @@ -18,14 +19,23 @@ class PositionDetailFragment : BaseFragment() { companion object { const val TAG = "PositionDetailFragment" private const val ARGS_POSITION = "args_position" + private const val ARGS_POSITION_HISTORY = "args_position_history" - fun newInstance(position: PositionHistoryView): PositionDetailFragment { + fun newInstance(position: PerpsPositionItem): PositionDetailFragment { return PositionDetailFragment().apply { arguments = Bundle().apply { putParcelable(ARGS_POSITION, position) } } } + + fun newInstance(position: PerpsPositionHistoryItem): PositionDetailFragment { + return PositionDetailFragment().apply { + arguments = Bundle().apply { + putParcelable(ARGS_POSITION_HISTORY, position) + } + } + } } private val viewModel by viewModels() @@ -35,20 +45,29 @@ class PositionDetailFragment : BaseFragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { - val position = arguments?.getParcelableCompat(ARGS_POSITION, PositionHistoryView::class.java) - ?: throw IllegalArgumentException("Position is required") + val position = arguments?.getParcelableCompat(ARGS_POSITION, PerpsPositionItem::class.java) + val positionHistory = arguments?.getParcelableCompat(ARGS_POSITION_HISTORY, PerpsPositionHistoryItem::class.java) return ComposeView(inflater.context).apply { setContent { MixinAppTheme( darkTheme = context.isNightMode(), ) { - PositionDetailPage( - position = position, - pop = { - activity?.onBackPressedDispatcher?.onBackPressed() - } - ) + if (position != null) { + PositionDetailPage( + position = position, + pop = { + activity?.onBackPressedDispatcher?.onBackPressed() + } + ) + } else if (positionHistory != null) { + PositionDetailPage( + positionHistory = positionHistory, + pop = { + activity?.onBackPressedDispatcher?.onBackPressed() + } + ) + } } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt index 8cd369c621..f02a1f6a62 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt @@ -28,7 +28,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R -import one.mixin.android.api.response.perps.PositionHistoryView +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.ui.wallet.alert.components.cardBackground @@ -38,12 +39,13 @@ import java.util.Locale @Composable fun PositionDetailPage( - position: PositionHistoryView, + position: PerpsPositionItem, pop: () -> Unit, ) { val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - fun formatDate(dateStr: String): String { + fun formatDate(dateStr: String?): String { + if (dateStr == null) return "" return try { val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) val date = inputFormat.parse(dateStr) @@ -54,7 +56,7 @@ fun PositionDetailPage( } val pnl = try { - BigDecimal(position.realizedPnl) + BigDecimal(position.unrealizedPnl ?: "0") } catch (e: Exception) { BigDecimal.ZERO } @@ -198,13 +200,6 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) - PositionDetailItem( - label = stringResource(R.string.Close_Price).uppercase(), - value = String.format("$%.2f", position.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) - ) - - Spacer(modifier = Modifier.height(20.dp)) - one.mixin.android.ui.tip.wc.compose.ItemWalletContent( title = stringResource(R.string.Wallet).uppercase(), fontSize = 16.sp, @@ -214,8 +209,8 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.Close_Time).uppercase(), - value = formatDate(position.closedAt) + label = stringResource(R.string.Open_Time).uppercase(), + value = formatDate(position.createdAt) ) } @@ -267,3 +262,193 @@ private fun PositionDetailItem( } } } + + +@Composable +fun PositionDetailPage( + positionHistory: PerpsPositionHistoryItem, + pop: () -> Unit, +) { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + fun formatDate(dateStr: String?): String { + if (dateStr == null) return "" + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + val date = inputFormat.parse(dateStr) + date?.let { dateFormat.format(it) } ?: dateStr + } catch (e: Exception) { + dateStr + } + } + + val pnl = try { + BigDecimal(positionHistory.realizedPnl) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isProfit = pnl >= BigDecimal.ZERO + val pnlColor = if (isProfit) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + + PageScaffold( + title = stringResource(R.string.Position_Details), + verticalScrollable = false, + pop = pop + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .cardBackground( + MixinAppTheme.colors.background, + MixinAppTheme.colors.borderColor + ) + ) { + Spacer(modifier = Modifier.height(30.dp)) + + CoilImage( + model = positionHistory.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(70.dp) + .clip(CircleShape) + .align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = String.format("$%.2f", pnl.abs()), + fontSize = 24.sp, + fontWeight = FontWeight.W500, + color = pnlColor, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(pnlColor.copy(alpha = 0.2f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + .align(Alignment.CenterHorizontally) + ) { + val sideText = if (positionHistory.side.lowercase() == "long") { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + Text( + text = "$sideText ${positionHistory.leverage}x", + color = pnlColor, + fontSize = 14.sp + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 20.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MixinAppTheme.colors.backgroundWindow), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.Trade_Again), + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + modifier = Modifier + .weight(1f) + .padding(vertical = 10.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Box( + modifier = Modifier + .width(2.dp) + .height(24.dp) + .background(Color(0x0D000000)) + ) + Text( + text = stringResource(R.string.Share), + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + modifier = Modifier + .weight(1f) + .padding(vertical = 10.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(30.dp)) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .wrapContentHeight() + .cardBackground( + MixinAppTheme.colors.background, + MixinAppTheme.colors.borderColor + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + PositionDetailItem( + label = stringResource(R.string.Perpetual).uppercase(), + value = positionHistory.displaySymbol ?: positionHistory.tokenSymbol ?: "Unknown", + icon = positionHistory.iconUrl + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Order_Value).uppercase(), + value = "${positionHistory.quantity.toBigDecimalOrNull()?.let { String.format("%.4f", it) } ?: positionHistory.quantity} ${positionHistory.tokenSymbol ?: ""}" + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Entry_Price).uppercase(), + value = String.format("$%.2f", positionHistory.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Close_Price).uppercase(), + value = String.format("$%.2f", positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + one.mixin.android.ui.tip.wc.compose.ItemWalletContent( + title = stringResource(R.string.Wallet).uppercase(), + fontSize = 16.sp, + padding = 0.dp + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Close_Time).uppercase(), + value = formatDate(positionHistory.closedAt) + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt new file mode 100644 index 0000000000..1e8e279707 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -0,0 +1,57 @@ +package one.mixin.android.ui.home.web3.trade + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.AttrRes +import androidx.recyclerview.widget.RecyclerView +import one.mixin.android.R +import java.math.BigDecimal + +class TotalPositionValueAdapter : RecyclerView.Adapter() { + private var totalValue: BigDecimal = BigDecimal.ZERO + + fun submitTotal(value: BigDecimal) { + totalValue = value + notifyItemChanged(0) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_total_position_value, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(totalValue) + } + + override fun getItemCount(): Int = 1 + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val valueTv: TextView = itemView.findViewById(R.id.value_tv) + + fun bind(total: BigDecimal) { + val context = itemView.context + valueTv.text = String.format("$%.2f", total) + valueTv.setTextColor( + when { + total > BigDecimal.ZERO -> context.getColor(R.color.wallet_green) + total < BigDecimal.ZERO -> context.getColor(R.color.wallet_red) + else -> resolveAttrColor(itemView, R.attr.text_primary) + } + ) + } + + private fun resolveAttrColor(view: View, @AttrRes attr: Int): Int { + val typedValue = android.util.TypedValue() + view.context.theme.resolveAttribute(attr, typedValue, true) + return if (typedValue.resourceId != 0) { + view.context.getColor(typedValue.resourceId) + } else { + typedValue.data + } + } + } +} 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 471e635d40..1524b99f69 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 @@ -337,7 +337,7 @@ class TradeFragment : BaseFragment() { MarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, MarketListBottomSheetDialogFragment.TAG) }, onShowAllClosedPositions = { - navTo(AllClosedPositionsFragment.newInstance(), AllClosedPositionsFragment.TAG) + navTo(AllPositionsFragment.newInstance(), AllPositionsFragment.TAG) } ) } 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 9590dac714..d2a6465c26 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 @@ -254,7 +254,12 @@ fun TradePage( actions = { Box { IconButton(onClick = { - onOrderList(currentWalletId, false) + // If on Perpetual tab (page 2), show closed positions + if (walletId == null && pagerState.currentPage == 2) { + onShowAllClosedPositions() + } else { + onOrderList(currentWalletId, false) + } }) { Icon( painter = painterResource(id = R.drawable.ic_order), diff --git a/app/src/main/res/layout/fragment_all_closed_positions.xml b/app/src/main/res/layout/fragment_all_closed_positions.xml index 0ade0cbeae..c55f06e634 100644 --- a/app/src/main/res/layout/fragment_all_closed_positions.xml +++ b/app/src/main/res/layout/fragment_all_closed_positions.xml @@ -11,6 +11,46 @@ android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/radio_group" /> + app:layout_constraintTop_toBottomOf="@id/radio_group" /> + app:layout_constraintTop_toBottomOf="@id/radio_group" /> diff --git a/app/src/main/res/layout/item_total_position_value.xml b/app/src/main/res/layout/item_total_position_value.xml new file mode 100644 index 0000000000..1107c078bb --- /dev/null +++ b/app/src/main/res/layout/item_total_position_value.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c4b9856a41..7121e821a3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2284,6 +2284,7 @@ 做空 方向 开仓价格 + 价格 平仓价格 数量 持仓详情 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 602ad767ad..c6ad2c6eaa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2343,6 +2343,7 @@ Short Side Entry Price + Mark Price Close Price Quantity Position Details @@ -2368,6 +2369,7 @@ Close Position Estimated Liquidation Price Total Position Value + Open Positions Open Positions(%1$d) Closed Positions No Positions From 80349f024fc40451962288a767b0fb08a3ec52c1 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 2 Mar 2026 17:03:45 +0800 Subject: [PATCH 015/105] Update positions page --- .../home/web3/trade/ClosedPositionAdapter.kt | 27 ++++++++++++----- .../ui/home/web3/trade/OpenPositionAdapter.kt | 27 ++++++++++++----- .../ui/home/web3/trade/PerpetualContent.kt | 4 +-- app/src/main/res/drawable/bg_card.xml | 2 +- app/src/main/res/drawable/bg_card_bottom.xml | 23 ++++++++++++++ app/src/main/res/drawable/bg_card_middle.xml | 30 +++++++++++++++++++ app/src/main/res/drawable/bg_card_top.xml | 23 ++++++++++++++ .../layout/fragment_all_closed_positions.xml | 2 ++ .../res/layout/item_closed_position_list.xml | 3 +- .../res/layout/item_total_position_value.xml | 2 ++ 10 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 app/src/main/res/drawable/bg_card_bottom.xml create mode 100644 app/src/main/res/drawable/bg_card_middle.xml create mode 100644 app/src/main/res/drawable/bg_card_top.xml diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt index 6cafc303e7..f92f9d6c2d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt @@ -27,7 +27,11 @@ class ClosedPositionAdapter( } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(getItem(position)) + holder.bind( + position = getItem(position), + positionInList = position, + listSize = itemCount + ) } class ViewHolder( @@ -35,19 +39,28 @@ class ClosedPositionAdapter( private val onItemClick: ((PerpsPositionHistoryItem) -> Unit)? ) : RecyclerView.ViewHolder(binding.root) { - fun bind(position: PerpsPositionHistoryItem) { + fun bind(position: PerpsPositionHistoryItem, positionInList: Int, listSize: Int) { binding.apply { val context = binding.root.context - + + root.setBackgroundResource( + when { + listSize <= 1 -> R.drawable.bg_card + positionInList == 0 -> R.drawable.bg_card_top + positionInList == listSize - 1 -> R.drawable.bg_card_bottom + else -> R.drawable.bg_card_middle + } + ) + root.setOnClickListener { onItemClick?.invoke(position) } - + iconIv.loadImage(position.iconUrl, R.drawable.ic_avatar_place_holder) - + val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" symbolTv.text = displaySymbol - + sideTv.text = if (position.side.lowercase() == "long") { context.getString(R.string.Long) } else { @@ -88,7 +101,7 @@ class ClosedPositionAdapter( } else { BigDecimal.ZERO } - + priceChangeTv.text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange) priceChangeTv.setTextColor( if (priceChange >= BigDecimal.ZERO) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt index 591e4c9d99..0e5aab864d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt @@ -27,7 +27,11 @@ class OpenPositionAdapter( } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(getItem(position)) + holder.bind( + position = getItem(position), + positionInList = position, + listSize = itemCount + ) } class ViewHolder( @@ -35,19 +39,28 @@ class OpenPositionAdapter( private val onItemClick: ((PerpsPositionItem) -> Unit)? ) : RecyclerView.ViewHolder(binding.root) { - fun bind(position: PerpsPositionItem) { + fun bind(position: PerpsPositionItem, positionInList: Int, listSize: Int) { binding.apply { val context = binding.root.context - + + root.setBackgroundResource( + when { + listSize <= 1 -> R.drawable.bg_card + positionInList == 0 -> R.drawable.bg_card_top + positionInList == listSize - 1 -> R.drawable.bg_card_bottom + else -> R.drawable.bg_card_middle + } + ) + root.setOnClickListener { onItemClick?.invoke(position) } - + iconIv.loadImage(position.iconUrl, R.drawable.ic_avatar_place_holder) - + val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" symbolTv.text = displaySymbol - + sideTv.text = if (position.side.lowercase() == "long") { context.getString(R.string.Long) } else { @@ -88,7 +101,7 @@ class OpenPositionAdapter( } else { BigDecimal.ZERO } - + priceChangeTv.text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange) priceChangeTv.setTextColor( if (priceChange >= BigDecimal.ZERO) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index a09b851bf5..ede141bc59 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -222,6 +222,7 @@ fun PerpetualContent( // Markets Section Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -230,7 +231,6 @@ fun PerpetualContent( fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) - Spacer(modifier = Modifier.width(4.dp)) Icon( painter = painterResource(R.drawable.ic_arrow_right), contentDescription = null, @@ -293,6 +293,7 @@ fun PerpetualContent( .padding(16.dp) ) { Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -301,7 +302,6 @@ fun PerpetualContent( fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) - Spacer(modifier = Modifier.width(4.dp)) Icon( painter = painterResource(R.drawable.ic_arrow_right), contentDescription = null, diff --git a/app/src/main/res/drawable/bg_card.xml b/app/src/main/res/drawable/bg_card.xml index 7725e6f319..e3cf343fe3 100644 --- a/app/src/main/res/drawable/bg_card.xml +++ b/app/src/main/res/drawable/bg_card.xml @@ -1,6 +1,6 @@ - + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_card_middle.xml b/app/src/main/res/drawable/bg_card_middle.xml new file mode 100644 index 0000000000..47cdc215a0 --- /dev/null +++ b/app/src/main/res/drawable/bg_card_middle.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_card_top.xml b/app/src/main/res/drawable/bg_card_top.xml new file mode 100644 index 0000000000..fa426a0c97 --- /dev/null +++ b/app/src/main/res/drawable/bg_card_top.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_all_closed_positions.xml b/app/src/main/res/layout/fragment_all_closed_positions.xml index c55f06e634..55fa726057 100644 --- a/app/src/main/res/layout/fragment_all_closed_positions.xml +++ b/app/src/main/res/layout/fragment_all_closed_positions.xml @@ -69,6 +69,8 @@ android:clipToPadding="false" android:paddingTop="8dp" android:paddingBottom="8dp" + android:paddingStart="20dp" + android:paddingEnd="20dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintTop_toBottomOf="@id/radio_group" /> diff --git a/app/src/main/res/layout/item_closed_position_list.xml b/app/src/main/res/layout/item_closed_position_list.xml index 82e02f11d3..450f47bda6 100644 --- a/app/src/main/res/layout/item_closed_position_list.xml +++ b/app/src/main/res/layout/item_closed_position_list.xml @@ -4,7 +4,8 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?attr/selectableItemBackground" + android:background="@drawable/bg_card" + android:foreground="?attr/selectableItemBackground" android:paddingStart="20dp" android:paddingTop="16dp" android:paddingEnd="20dp" diff --git a/app/src/main/res/layout/item_total_position_value.xml b/app/src/main/res/layout/item_total_position_value.xml index 1107c078bb..99ae84ef04 100644 --- a/app/src/main/res/layout/item_total_position_value.xml +++ b/app/src/main/res/layout/item_total_position_value.xml @@ -2,6 +2,8 @@ Date: Mon, 2 Mar 2026 17:14:55 +0800 Subject: [PATCH 016/105] Update database schema --- .../one.mixin.android.db.PerpsDatabase/1.json | 21 +++---------------- .../android/api/response/perps/PerpsExt.kt | 21 ------------------- .../api/response/perps/PerpsPosition.kt | 6 ------ .../api/response/perps/PerpsPositionItem.kt | 2 +- app/src/main/res/drawable/bg_card_middle.xml | 2 +- 5 files changed, 5 insertions(+), 47 deletions(-) diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index 4820611bc6..7a313a34a4 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "28441fcf48069a0899c98e75eaacf4c9", + "identityHash": "aa2a40bfcda9610faa30a7e0d18637fa", "entities": [ { "tableName": "positions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `settle_asset_id` TEXT, `bot_id` TEXT, `margin` TEXT, `state` TEXT, `mark_price` TEXT, `unrealized_pnl` TEXT, `roe` TEXT, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, `display_symbol` TEXT, `icon_url` TEXT, `token_symbol` TEXT, PRIMARY KEY(`position_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `settle_asset_id` TEXT, `bot_id` TEXT, `margin` TEXT, `state` TEXT, `mark_price` TEXT, `unrealized_pnl` TEXT, `roe` TEXT, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, PRIMARY KEY(`position_id`))", "fields": [ { "fieldPath": "positionId", @@ -93,21 +93,6 @@ "fieldPath": "updatedAt", "columnName": "updated_at", "affinity": "TEXT" - }, - { - "fieldPath": "displaySymbol", - "columnName": "display_symbol", - "affinity": "TEXT" - }, - { - "fieldPath": "iconUrl", - "columnName": "icon_url", - "affinity": "TEXT" - }, - { - "fieldPath": "tokenSymbol", - "columnName": "token_symbol", - "affinity": "TEXT" } ], "primaryKey": { @@ -346,7 +331,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '28441fcf48069a0899c98e75eaacf4c9')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aa2a40bfcda9610faa30a7e0d18637fa')" ] } } \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt index 283def9a90..49fd01b2d7 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt @@ -18,27 +18,6 @@ fun PerpsPositionItem.toPosition(): PerpsPosition { walletId = walletId, createdAt = createdAt, updatedAt = updatedAt, - displaySymbol = displaySymbol, - iconUrl = iconUrl, - tokenSymbol = tokenSymbol ) } -fun PerpsPositionHistoryItem.toPositionHistory(): PerpsPositionHistory { - return PerpsPositionHistory( - historyId = historyId, - positionId = positionId, - productId = productId, - marketSymbol = marketSymbol, - side = side, - quantity = quantity, - entryPrice = entryPrice, - closePrice = closePrice, - realizedPnl = realizedPnl, - leverage = leverage, - marginMethod = marginMethod, - openAt = openAt, - closedAt = closedAt, - walletId = walletId - ) -} diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt index 32ca2b4c62..eb478b32c5 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt @@ -58,10 +58,4 @@ data class PerpsPosition( @SerializedName("updated_at") @ColumnInfo(name = "updated_at") val updatedAt: String? = null, - @ColumnInfo(name = "display_symbol") - var displaySymbol: String? = null, - @ColumnInfo(name = "icon_url") - var iconUrl: String? = null, - @ColumnInfo(name = "token_symbol") - var tokenSymbol: String? = null ) : Parcelable diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt index f158e91140..2c880e60ac 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt @@ -76,6 +76,6 @@ fun PerpsPositionItem.toPosotion(): PerpsPosition { state, markPrice, unrealizedPnl, - roe, walletId, createdAt, updatedAt, displaySymbol, iconUrl, tokenSymbol, + roe, walletId, createdAt, updatedAt ) } \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_card_middle.xml b/app/src/main/res/drawable/bg_card_middle.xml index 47cdc215a0..3e58cec997 100644 --- a/app/src/main/res/drawable/bg_card_middle.xml +++ b/app/src/main/res/drawable/bg_card_middle.xml @@ -14,7 +14,7 @@ android:right="1dp"> - + From ffee14b30167ef346d44405e41ab84fbc326d141 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 2 Mar 2026 22:40:27 +0800 Subject: [PATCH 017/105] Update positions page --- .../android/db/perps/PerpsPositionDao.kt | 10 ++ .../db/perps/PerpsPositionHistoryDao.kt | 13 ++ .../home/web3/trade/AllPositionsFragment.kt | 84 ++++++----- .../home/web3/trade/ClosedPositionAdapter.kt | 109 ++++++++------ .../ui/home/web3/trade/OpenPositionAdapter.kt | 117 +++++++++------ .../ui/home/web3/trade/OpenPositionItem.kt | 125 ++++++++++++++++ .../ui/home/web3/trade/PerpetualContent.kt | 82 ++++++----- .../ui/home/web3/trade/PerpetualViewModel.kt | 51 +++++++ .../web3/trade/TotalPositionValueAdapter.kt | 16 +- app/src/main/res/drawable/bg_card_middle.xml | 2 +- .../res/drawable/bg_perps_leverage_long.xml | 9 ++ .../res/drawable/bg_perps_leverage_short.xml | 9 ++ .../res/layout/item_closed_position_list.xml | 137 ++++++++++-------- app/src/main/res/values-zh-rCN/strings.xml | 5 + app/src/main/res/values/strings.xml | 4 + 15 files changed, 538 insertions(+), 235 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt create mode 100644 app/src/main/res/drawable/bg_perps_leverage_long.xml create mode 100644 app/src/main/res/drawable/bg_perps_leverage_short.xml diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index eeb17ed062..9b52098885 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.paging.DataSource import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.db.BaseDao @@ -25,6 +26,15 @@ interface PerpsPositionDao : BaseDao { """) suspend fun getOpenPositions(walletId: String): List + @Query(""" + SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol + FROM positions p + LEFT JOIN markets m ON m.market_id = p.product_id + WHERE p.wallet_id = :walletId AND p.state = 'open' + ORDER BY p.created_at DESC + """) + fun getOpenPositionsPaged(walletId: String): DataSource.Factory + @Query(""" SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt index a6b86bb2bf..14aaa116bd 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.paging.DataSource import one.mixin.android.api.response.perps.PerpsPositionHistory import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.db.BaseDao @@ -26,6 +27,15 @@ interface PerpsPositionHistoryDao : BaseDao { """) suspend fun getHistories(walletId: String, limit: Int): List + @Query(""" + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + FROM position_history h + LEFT JOIN markets m ON m.market_id = h.product_id + WHERE h.wallet_id = :walletId + ORDER BY h.closed_at DESC + """) + fun getHistoriesPaged(walletId: String): DataSource.Factory + @Query(""" SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol FROM position_history h @@ -36,4 +46,7 @@ interface PerpsPositionHistoryDao : BaseDao { @Query("DELETE FROM position_history WHERE wallet_id = :walletId") suspend fun deleteByWallet(walletId: String) + + @Query("SELECT SUM(CAST(realized_pnl AS REAL)) FROM position_history WHERE wallet_id = :walletId") + suspend fun getTotalRealizedPnl(walletId: String): Double? } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt index c70a5f8c2b..fe81a9fe4d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt @@ -4,7 +4,10 @@ import android.os.Bundle import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.viewModels +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope +import androidx.paging.PagedList import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint @@ -59,6 +62,24 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions } private var currentTab: PositionTab = PositionTab.CLOSED + private var openPositionsLiveData: LiveData>? = null + private var closedPositionsLiveData: LiveData>? = null + + private val openPositionsObserver = Observer> { pagedList -> + binding.progressBar.isVisible = false + openPositionAdapter.submitList(pagedList) + val isEmpty = pagedList.isEmpty() + binding.emptyView.infoTv.text = getString(R.string.No_Positions) + binding.emptyView.root.isVisible = isEmpty + } + + private val closedPositionsObserver = Observer> { pagedList -> + binding.progressBar.isVisible = false + closedPositionAdapter.submitList(pagedList) + val isEmpty = pagedList.isEmpty() + binding.emptyView.infoTv.text = getString(R.string.No_Closed_Positions) + binding.emptyView.root.isVisible = isEmpty + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -86,72 +107,55 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions } private fun loadPositions() { + openPositionsLiveData?.removeObservers(viewLifecycleOwner) + closedPositionsLiveData?.removeObservers(viewLifecycleOwner) + if (currentTab == PositionTab.OPEN) { binding.titleView.setSubTitle(getString(R.string.Open_Positions_Simple), "") + totalValueAdapter.submitTitle(R.string.Total_Position_Value) binding.positionsRv.adapter = ConcatAdapter(totalValueAdapter, openPositionAdapter) loadOpenPositions() } else { binding.titleView.setSubTitle(getString(R.string.Closed_Positions), "") + totalValueAdapter.submitTitle(R.string.Realized_PnL) binding.positionsRv.adapter = ConcatAdapter(totalValueAdapter, closedPositionAdapter) loadClosedPositions() } } private fun loadOpenPositions() { - val walletId = Session.getAccountId() ?: return + val walletId = Session.getAccountId() ?: run { + binding.progressBar.isVisible = false + return + } binding.progressBar.isVisible = true binding.emptyView.root.isVisible = false + openPositionsLiveData = viewModel.getOpenPositionsPaged(walletId) + openPositionsLiveData?.observe(viewLifecycleOwner, openPositionsObserver) + lifecycleScope.launch { - val positions = viewModel.getOpenPositionsFromDb(walletId) - binding.progressBar.isVisible = false - if (positions.isEmpty()) { - openPositionAdapter.submitList(emptyList()) - binding.emptyView.infoTv.text = getString(R.string.No_Positions) - binding.emptyView.root.isVisible = true - totalValueAdapter.submitTotal(BigDecimal.ZERO) - } else { - binding.emptyView.root.isVisible = false - openPositionAdapter.submitList(positions) - totalValueAdapter.submitTotal(sumOpenPnl(positions)) - } + val totalPnl = viewModel.getTotalUnrealizedPnlFromDb(walletId) + totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) } } private fun loadClosedPositions() { - val walletId = Session.getAccountId() ?: return + val walletId = Session.getAccountId() ?: run { + binding.progressBar.isVisible = false + return + } binding.progressBar.isVisible = true binding.emptyView.root.isVisible = false - lifecycleScope.launch { - val positions = viewModel.getClosedPositionsFromDb(walletId, 100) - binding.progressBar.isVisible = false - if (positions.isEmpty()) { - closedPositionAdapter.submitList(emptyList()) - binding.emptyView.infoTv.text = getString(R.string.No_Closed_Positions) - binding.emptyView.root.isVisible = true - totalValueAdapter.submitTotal(BigDecimal.ZERO) - } else { - binding.emptyView.root.isVisible = false - closedPositionAdapter.submitList(positions) - totalValueAdapter.submitTotal(sumClosedPnl(positions)) - } - } - } - - private fun sumOpenPnl(positions: List): BigDecimal { - return positions.fold(BigDecimal.ZERO) { total, position -> - val pnl = (position.unrealizedPnl ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO - total + pnl - } - } + closedPositionsLiveData = viewModel.getClosedPositionsPaged(walletId) + closedPositionsLiveData?.observe(viewLifecycleOwner, closedPositionsObserver) - private fun sumClosedPnl(positions: List): BigDecimal { - return positions.fold(BigDecimal.ZERO) { total, position -> - val pnl = position.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO - total + pnl + lifecycleScope.launch { + val totalPnl = viewModel.getTotalRealizedPnlFromDb(walletId) + totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt index f92f9d6c2d..3beca6180c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt @@ -1,19 +1,26 @@ package one.mixin.android.ui.home.web3.trade +import android.content.Context +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.view.View import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.AttrRes +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.databinding.ItemClosedPositionListBinding import one.mixin.android.extension.loadImage +import one.mixin.android.ui.common.recyclerview.SafePagedListAdapter import java.math.BigDecimal class ClosedPositionAdapter( private val onItemClick: ((PerpsPositionHistoryItem) -> Unit)? = null -) : ListAdapter(DiffCallback()) { +) : SafePagedListAdapter(DiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return ViewHolder( @@ -27,11 +34,8 @@ class ClosedPositionAdapter( } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind( - position = getItem(position), - positionInList = position, - listSize = itemCount - ) + val item = getItem(position) ?: return + holder.bind(position = item, positionInList = position, listSize = itemCount) } class ViewHolder( @@ -58,58 +62,69 @@ class ClosedPositionAdapter( iconIv.loadImage(position.iconUrl, R.drawable.ic_avatar_place_holder) - val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" - symbolTv.text = displaySymbol - - sideTv.text = if (position.side.lowercase() == "long") { + val isLong = position.side.equals("long", ignoreCase = true) + val sideText = if (isLong) { context.getString(R.string.Long) } else { context.getString(R.string.Short) } - sideTv.setTextColor( - if (position.side.lowercase() == "long") { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) - } - ) - - leverageTv.text = "${position.leverage}x" - - val quantity = position.quantity.toBigDecimalOrNull() - val quantityStr = if (quantity != null) { - String.format("%.4f", quantity) + val sideColor = if (isLong) { + context.getColor(R.color.wallet_green) } else { - position.quantity + context.getColor(R.color.wallet_red) } + val displaySymbol = position.tokenSymbol ?: context.getString(R.string.Unknown) + titleTv.text = context.getString(R.string.Perpetual_Side_Symbol_Title, sideText, displaySymbol) + + leverageTv.isVisible = false + + val quantityStr = position.quantity quantityTv.text = "$quantityStr ${position.tokenSymbol ?: ""}" - val pnl = (position.realizedPnl).toBigDecimalOrNull() ?: BigDecimal.ZERO - pnlTv.text = String.format("$%.2f", pnl.abs()) - pnlTv.setTextColor( - if (pnl >= BigDecimal.ZERO) { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) - } - ) + val pnl = position.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO + rightTopValueTv.text = formatSignedUsd(context, pnl) + rightTopValueTv.setTextColor( + when { + pnl > BigDecimal.ZERO -> { + context.getColor(R.color.wallet_green) + } - val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - val closePrice = position.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - val priceChange = if (entryPrice > BigDecimal.ZERO) { - ((closePrice - entryPrice) / entryPrice * BigDecimal(100)) - } else { - BigDecimal.ZERO - } + pnl < BigDecimal.ZERO -> { + context.getColor(R.color.wallet_red) + } - priceChangeTv.text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange) - priceChangeTv.setTextColor( - if (priceChange >= BigDecimal.ZERO) { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) + else -> { + resolveAttrColor(root, R.attr.text_primary) + } } ) + rightBottomValueTv.isVisible = false + } + } + + private fun formatSignedUsd(context: Context, amount: BigDecimal): String { + return when { + amount > BigDecimal.ZERO -> context.getString( + R.string.Perpetual_Usd_Amount_Signed, + "+", + amount.abs().toDouble() + ) + amount < BigDecimal.ZERO -> context.getString( + R.string.Perpetual_Usd_Amount_Signed, + "-", + amount.abs().toDouble() + ) + else -> context.getString(R.string.Perpetual_Usd_Amount, 0.0) + } + } + + private fun resolveAttrColor(view: View, @AttrRes attr: Int): Int { + val typedValue = android.util.TypedValue() + view.context.theme.resolveAttribute(attr, typedValue, true) + return if (typedValue.resourceId != 0) { + view.context.getColor(typedValue.resourceId) + } else { + typedValue.data } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt index 0e5aab864d..2bc917d29a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt @@ -1,19 +1,26 @@ package one.mixin.android.ui.home.web3.trade +import android.content.Context +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.view.View import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.AttrRes +import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.databinding.ItemClosedPositionListBinding import one.mixin.android.extension.loadImage +import one.mixin.android.ui.common.recyclerview.SafePagedListAdapter import java.math.BigDecimal class OpenPositionAdapter( private val onItemClick: ((PerpsPositionItem) -> Unit)? = null -) : ListAdapter(DiffCallback()) { +) : SafePagedListAdapter(DiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return ViewHolder( @@ -27,11 +34,8 @@ class OpenPositionAdapter( } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind( - position = getItem(position), - positionInList = position, - listSize = itemCount - ) + val item = getItem(position) ?: return + holder.bind(position = item, positionInList = position, listSize = itemCount) } class ViewHolder( @@ -58,58 +62,83 @@ class OpenPositionAdapter( iconIv.loadImage(position.iconUrl, R.drawable.ic_avatar_place_holder) - val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" - symbolTv.text = displaySymbol - - sideTv.text = if (position.side.lowercase() == "long") { + val isLong = position.side.equals("long", ignoreCase = true) + val sideText = if (isLong) { context.getString(R.string.Long) } else { context.getString(R.string.Short) } - sideTv.setTextColor( - if (position.side.lowercase() == "long") { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) - } + val sideColor = if (isLong) { + context.getColor(R.color.wallet_green) + } else { + context.getColor(R.color.wallet_red) + } + val displaySymbol = position.tokenSymbol ?: context.getString(R.string.Unknown) + titleTv.text = context.getString(R.string.Perpetual_Side_Symbol_Title, sideText, displaySymbol) + leverageTv.isVisible = true + leverageTv.text = context.getString(R.string.Perpetual_Leverage_Format, position.leverage) + leverageTv.setTextColor(sideColor) + leverageTv.setBackgroundResource( + if (isLong) R.drawable.bg_perps_leverage_long else R.drawable.bg_perps_leverage_short ) - leverageTv.text = "${position.leverage}x" - val quantity = position.quantity.toBigDecimalOrNull() - val quantityStr = if (quantity != null) { - String.format("%.4f", quantity) - } else { - position.quantity - } + val quantityStr = position.quantity quantityTv.text = "$quantityStr ${position.tokenSymbol ?: ""}" + val markPrice = (position.markPrice ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO + val positionValue = (quantity ?: BigDecimal.ZERO).abs().multiply(markPrice) + rightTopValueTv.text = formatUsd(context, positionValue) + rightTopValueTv.setTextColor(resolveAttrColor(root, R.attr.text_primary)) + + rightBottomValueTv.isVisible = true val pnl = (position.unrealizedPnl ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO - pnlTv.text = String.format("$%.2f", pnl.abs()) - pnlTv.setTextColor( - if (pnl >= BigDecimal.ZERO) { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) + rightBottomValueTv.text = formatSignedUsd(context, pnl) + rightBottomValueTv.setTextColor( + when { + pnl > BigDecimal.ZERO -> { + context.getColor(R.color.wallet_green) + } + + pnl < BigDecimal.ZERO -> { + context.getColor(R.color.wallet_red) + } + + else -> { + resolveAttrColor(root, R.attr.text_primary) + } } ) + } + } - val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - val markPrice = (position.markPrice ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO - val priceChange = if (entryPrice > BigDecimal.ZERO) { - ((markPrice - entryPrice) / entryPrice * BigDecimal(100)) - } else { - BigDecimal.ZERO - } + private fun formatUsd(context: Context, amount: BigDecimal): String { + return context.getString(R.string.Perpetual_Usd_Amount, amount.toDouble()) + } - priceChangeTv.text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange) - priceChangeTv.setTextColor( - if (priceChange >= BigDecimal.ZERO) { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) - } + private fun formatSignedUsd(context: Context, amount: BigDecimal): String { + return when { + amount > BigDecimal.ZERO -> context.getString( + R.string.Perpetual_Usd_Amount_Signed, + "+", + amount.abs().toDouble() + ) + amount < BigDecimal.ZERO -> context.getString( + R.string.Perpetual_Usd_Amount_Signed, + "-", + amount.abs().toDouble() ) + else -> context.getString(R.string.Perpetual_Usd_Amount, 0.0) + } + } + + private fun resolveAttrColor(view: View, @AttrRes attr: Int): Int { + val typedValue = android.util.TypedValue() + view.context.theme.resolveAttribute(attr, typedValue, true) + return if (typedValue.resourceId != 0) { + view.context.getColor(typedValue.resourceId) + } else { + typedValue.data } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt new file mode 100644 index 0000000000..3bd130b1f9 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt @@ -0,0 +1,125 @@ +package one.mixin.android.ui.home.web3.trade + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.ui.wallet.alert.components.cardBackground +import java.math.BigDecimal + +@Composable +fun OpenPositionItem(position: PerpsPositionItem) { + val pnl = position.unrealizedPnl?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val isProfit = pnl >= BigDecimal.ZERO + val pnlColor = if (isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) + + val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: stringResource(R.string.Unknown) + val quantity = position.quantity.toBigDecimalOrNull()?.let { String.format("%.4f", it) } ?: position.quantity + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + CoilImage( + model = position.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + val sideText = if (position.side.equals("long", true)) { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + val sideColor = if (position.side.equals("long", true)) Color(0xFF4CAF50) else Color(0xFFF44336) + Text( + text = sideText, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = sideColor + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = displaySymbol, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "${position.leverage}x", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .cardBackground(MixinAppTheme.colors.backgroundWindow, Color.Transparent) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "$quantity ${position.tokenSymbol ?: ""}", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = String.format("$%.2f", pnl.abs()), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = pnlColor + ) + Spacer(modifier = Modifier.height(2.dp)) + val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceChange = if (entryPrice > BigDecimal.ZERO) { + ((markPrice - entryPrice) / entryPrice * BigDecimal(100)) + } else { + BigDecimal.ZERO + } + Text( + text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange), + fontSize = 12.sp, + color = if (priceChange >= BigDecimal.ZERO) Color(0xFF4CAF50) else Color(0xFFF44336) + ) + } + } +} + diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index ede141bc59..e2cad63996 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.session.Session @@ -61,6 +62,7 @@ fun PerpetualContent( var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } var openPositionsCount by remember { mutableStateOf(0) } + var openPositions by remember { mutableStateOf>(emptyList()) } var totalPnl by remember { mutableStateOf(0.0) } var closedPositions by remember { mutableStateOf>(emptyList()) } var isLoadingHistory by remember { mutableStateOf(false) } @@ -82,6 +84,7 @@ fun PerpetualContent( if (walletId.isNotEmpty()) { viewModel.getOpenPositions(walletId) { positions -> + openPositions = positions openPositionsCount = positions.size } @@ -170,42 +173,48 @@ fun PerpetualContent( color = MixinAppTheme.colors.textPrimary, ) } - Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painter = painterResource(id = R.drawable.ic_empty_transaction), - contentDescription = null, - tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(78.dp) - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(R.string.No_Positions), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, - ) - } - - Spacer(modifier = Modifier.height(10.dp)) + if (openPositionsCount == 0) { + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.ic_empty_transaction), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(78.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.No_Positions), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + Spacer(modifier = Modifier.height(10.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { - onShowTradingGuide() - }) - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.How_Perps_Works), - fontSize = 14.sp, - color = MixinAppTheme.colors.accent, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { + onShowTradingGuide() + }) + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.How_Perps_Works), + fontSize = 14.sp, + color = MixinAppTheme.colors.accent, + ) + } + } else { + openPositions.forEach { position -> + OpenPositionItem(position = position) + Spacer(modifier = Modifier.height(12.dp)) + } } } @@ -337,9 +346,8 @@ fun PerpetualContent( painter = painterResource(id = R.drawable.ic_empty_transaction), contentDescription = null, tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(48.dp) + modifier = Modifier.size(78.dp) ) - Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.No_Closed_Positions), fontSize = 14.sp, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index ac68bfabfc..f914afec0d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -2,10 +2,14 @@ package one.mixin.android.ui.home.web3.trade import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.lifecycle.LiveData +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import one.mixin.android.Constants import one.mixin.android.api.response.perps.CandleView import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.service.RouteService @@ -280,6 +284,53 @@ class PerpetualViewModel @Inject constructor( } } + suspend fun getTotalUnrealizedPnlFromDb(walletId: String): Double { + return withContext(Dispatchers.IO) { + try { + perpsPositionDao.getTotalUnrealizedPnl(walletId) ?: 0.0 + } catch (e: Exception) { + Timber.e(e, "Error loading total unrealized PnL from db") + 0.0 + } + } + } + + suspend fun getTotalRealizedPnlFromDb(walletId: String): Double { + return withContext(Dispatchers.IO) { + try { + perpsPositionHistoryDao.getTotalRealizedPnl(walletId) ?: 0.0 + } catch (e: Exception) { + Timber.e(e, "Error loading total realized PnL from db") + 0.0 + } + } + } + + fun getOpenPositionsPaged(walletId: String, initialLoadKey: Int? = 0): LiveData> { + val config = PagedList.Config.Builder() + .setPrefetchDistance(Constants.PAGE_SIZE * 2) + .setPageSize(Constants.PAGE_SIZE) + .setEnablePlaceholders(false) + .build() + return LivePagedListBuilder(perpsPositionDao.getOpenPositionsPaged(walletId), config) + .setInitialLoadKey(initialLoadKey) + .build() + } + + fun getClosedPositionsPaged( + walletId: String, + initialLoadKey: Int? = 0 + ): LiveData> { + val config = PagedList.Config.Builder() + .setPrefetchDistance(Constants.PAGE_SIZE * 2) + .setPageSize(Constants.PAGE_SIZE) + .setEnablePlaceholders(false) + .build() + return LivePagedListBuilder(perpsPositionHistoryDao.getHistoriesPaged(walletId), config) + .setInitialLoadKey(initialLoadKey) + .build() + } + fun loadOpenPositions(walletId: String, onSuccess: (List) -> Unit) { viewModelScope.launch { try { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index 1e8e279707..69551d4abd 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -5,18 +5,26 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.annotation.AttrRes +import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import one.mixin.android.R import java.math.BigDecimal class TotalPositionValueAdapter : RecyclerView.Adapter() { private var totalValue: BigDecimal = BigDecimal.ZERO + @StringRes + private var titleResId: Int = R.string.Total_Position_Value fun submitTotal(value: BigDecimal) { totalValue = value notifyItemChanged(0) } + fun submitTitle(@StringRes titleResId: Int) { + this.titleResId = titleResId + notifyItemChanged(0) + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_total_position_value, parent, false) @@ -24,17 +32,19 @@ class TotalPositionValueAdapter : RecyclerView.Adapter BigDecimal.ZERO -> context.getColor(R.color.wallet_green) diff --git a/app/src/main/res/drawable/bg_card_middle.xml b/app/src/main/res/drawable/bg_card_middle.xml index 3e58cec997..47cdc215a0 100644 --- a/app/src/main/res/drawable/bg_card_middle.xml +++ b/app/src/main/res/drawable/bg_card_middle.xml @@ -14,7 +14,7 @@ android:right="1dp"> - + diff --git a/app/src/main/res/drawable/bg_perps_leverage_long.xml b/app/src/main/res/drawable/bg_perps_leverage_long.xml new file mode 100644 index 0000000000..a621d6a6d1 --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_leverage_long.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_perps_leverage_short.xml b/app/src/main/res/drawable/bg_perps_leverage_short.xml new file mode 100644 index 0000000000..66ae29a1ed --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_leverage_short.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/item_closed_position_list.xml b/app/src/main/res/layout/item_closed_position_list.xml index 450f47bda6..77c82e800e 100644 --- a/app/src/main/res/layout/item_closed_position_list.xml +++ b/app/src/main/res/layout/item_closed_position_list.xml @@ -19,79 +19,90 @@ app:layout_constraintTop_toTopOf="parent" tools:src="@tools:sample/avatars" /> - + app:layout_constraintTop_toTopOf="parent"> - + - + - + + - + + + + app:layout_constraintTop_toTopOf="parent"> - + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7121e821a3..70f49646f3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2304,6 +2304,11 @@ 平仓 预估强平价格 持仓总价值 + %1$s %2$s + %1$d倍 + $%1$f + %1$s$%2$f + 持仓中 持仓中(%1$d) 已平仓 暂无持仓 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6ad2c6eaa..b299e3c23b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2369,6 +2369,10 @@ Close Position Estimated Liquidation Price Total Position Value + %1$s %2$s + %1$dx + $%1$f + %1$s$%2$f Open Positions Open Positions(%1$d) Closed Positions From f8dea9cb6263011919b175ce5e30facdcd1e94f0 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 3 Mar 2026 10:24:13 +0800 Subject: [PATCH 018/105] Update total --- .../api/response/perps/PerpsPositionItem.kt | 20 +--------- .../android/db/perps/PerpsPositionDao.kt | 3 ++ .../db/perps/PerpsPositionHistoryDao.kt | 3 ++ .../home/web3/trade/AllPositionsFragment.kt | 19 +++++++++- .../ui/home/web3/trade/PerpetualViewModel.kt | 22 +++++++++++ .../web3/trade/TotalPositionValueAdapter.kt | 38 +++++++++++++++---- .../res/layout/item_total_position_value.xml | 9 +++++ app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values/strings.xml | 5 ++- 9 files changed, 89 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt index 2c880e60ac..21084d719b 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt @@ -60,22 +60,4 @@ data class PerpsPositionItem( val iconUrl: String? = null, @ColumnInfo(name = "token_symbol") val tokenSymbol: String? = null, -) : Parcelable - -fun PerpsPositionItem.toPosotion(): PerpsPosition { - return PerpsPosition( - positionId, - productId, - side, - quantity, - entryPrice, - leverage, - settleAssetId, - botId, - margin, - state, - markPrice, - unrealizedPnl, - roe, walletId, createdAt, updatedAt - ) -} \ No newline at end of file +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index 9b52098885..696c037a8d 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -51,4 +51,7 @@ interface PerpsPositionDao : BaseDao { @Query("SELECT SUM(CAST(unrealized_pnl AS REAL)) FROM positions WHERE wallet_id = :walletId AND state = 'open'") suspend fun getTotalUnrealizedPnl(walletId: String): Double? + + @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM positions WHERE wallet_id = :walletId AND state = 'open'") + suspend fun getTotalOpenPositionValue(walletId: String): Double? } diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt index 14aaa116bd..413bdffa5f 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -49,4 +49,7 @@ interface PerpsPositionHistoryDao : BaseDao { @Query("SELECT SUM(CAST(realized_pnl AS REAL)) FROM position_history WHERE wallet_id = :walletId") suspend fun getTotalRealizedPnl(walletId: String): Double? + + @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM position_history WHERE wallet_id = :walletId") + suspend fun getTotalClosedEntryValue(walletId: String): Double? } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt index fe81a9fe4d..264ffa680b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt @@ -131,13 +131,18 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions binding.progressBar.isVisible = true binding.emptyView.root.isVisible = false + totalValueAdapter.submitTotal(BigDecimal.ZERO) + totalValueAdapter.submitSubtitle(BigDecimal.ZERO, BigDecimal.ZERO) openPositionsLiveData = viewModel.getOpenPositionsPaged(walletId) openPositionsLiveData?.observe(viewLifecycleOwner, openPositionsObserver) lifecycleScope.launch { + val totalPositionValue = viewModel.getTotalOpenPositionValueFromDb(walletId) val totalPnl = viewModel.getTotalUnrealizedPnlFromDb(walletId) - totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) + val percent = calculatePercent(totalPnl, totalPositionValue) + totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPositionValue)) + totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) } } @@ -149,13 +154,25 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions binding.progressBar.isVisible = true binding.emptyView.root.isVisible = false + totalValueAdapter.submitTotal(BigDecimal.ZERO) + totalValueAdapter.submitSubtitle(BigDecimal.ZERO, BigDecimal.ZERO) closedPositionsLiveData = viewModel.getClosedPositionsPaged(walletId) closedPositionsLiveData?.observe(viewLifecycleOwner, closedPositionsObserver) lifecycleScope.launch { val totalPnl = viewModel.getTotalRealizedPnlFromDb(walletId) + val totalEntryValue = viewModel.getTotalClosedEntryValueFromDb(walletId) + val percent = calculatePercent(totalPnl, totalEntryValue) totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) + totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + } + } + + private fun calculatePercent(value: Double, base: Double): Double { + if (base == 0.0) { + return 0.0 } + return value / base * 100 } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index f914afec0d..f84f516906 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -295,6 +295,17 @@ class PerpetualViewModel @Inject constructor( } } + suspend fun getTotalOpenPositionValueFromDb(walletId: String): Double { + return withContext(Dispatchers.IO) { + try { + perpsPositionDao.getTotalOpenPositionValue(walletId) ?: 0.0 + } catch (e: Exception) { + Timber.e(e, "Error loading total open position value from db") + 0.0 + } + } + } + suspend fun getTotalRealizedPnlFromDb(walletId: String): Double { return withContext(Dispatchers.IO) { try { @@ -306,6 +317,17 @@ class PerpetualViewModel @Inject constructor( } } + suspend fun getTotalClosedEntryValueFromDb(walletId: String): Double { + return withContext(Dispatchers.IO) { + try { + perpsPositionHistoryDao.getTotalClosedEntryValue(walletId) ?: 0.0 + } catch (e: Exception) { + Timber.e(e, "Error loading total closed entry value from db") + 0.0 + } + } + } + fun getOpenPositionsPaged(walletId: String, initialLoadKey: Int? = 0): LiveData> { val config = PagedList.Config.Builder() .setPrefetchDistance(Constants.PAGE_SIZE * 2) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index 69551d4abd..de19292c1c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -12,6 +12,8 @@ import java.math.BigDecimal class TotalPositionValueAdapter : RecyclerView.Adapter() { private var totalValue: BigDecimal = BigDecimal.ZERO + private var subValue: BigDecimal = BigDecimal.ZERO + private var subPercent: BigDecimal = BigDecimal.ZERO @StringRes private var titleResId: Int = R.string.Total_Position_Value @@ -20,6 +22,12 @@ class TotalPositionValueAdapter : RecyclerView.Adapter BigDecimal.ZERO -> context.getColor(R.color.wallet_green) - total < BigDecimal.ZERO -> context.getColor(R.color.wallet_red) - else -> resolveAttrColor(itemView, R.attr.text_primary) - } + valueTv.setTextColor(resolveAttrColor(itemView, R.attr.text_primary)) + subtitleTv.text = context.getString( + R.string.Perpetual_Amount_Percent_Format, + formatSignedUsd(context, subtitleValue), + subtitlePercent.toDouble() ) + subtitleTv.setTextColor(resolveAttrColor(itemView, R.attr.text_assist)) + } + + private fun formatSignedUsd(context: android.content.Context, amount: BigDecimal): String { + val sign = when { + amount < BigDecimal.ZERO -> "-" + else -> "" + } + return context.getString(R.string.Perpetual_Usd_Amount_Signed, sign, amount.abs().toDouble()) } private fun resolveAttrColor(view: View, @AttrRes attr: Int): Int { diff --git a/app/src/main/res/layout/item_total_position_value.xml b/app/src/main/res/layout/item_total_position_value.xml index 99ae84ef04..22d7db127e 100644 --- a/app/src/main/res/layout/item_total_position_value.xml +++ b/app/src/main/res/layout/item_total_position_value.xml @@ -28,4 +28,13 @@ android:textSize="22sp" android:textStyle="bold" /> + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 70f49646f3..e5c11379f1 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2306,8 +2306,6 @@ 持仓总价值 %1$s %2$s %1$d倍 - $%1$f - %1$s$%2$f 持仓中 持仓中(%1$d) 已平仓 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b299e3c23b..a663b4ff97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2371,8 +2371,9 @@ Total Position Value %1$s %2$s %1$dx - $%1$f - %1$s$%2$f + $%1$f + %1$s$%2$f + %1$s(%2$.2f%%) Open Positions Open Positions(%1$d) Closed Positions From a9937e41031cceb15eba14cd2f0163de787ca800 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 3 Mar 2026 11:01:45 +0800 Subject: [PATCH 019/105] Update position item --- .../ui/home/web3/trade/ClosedPositionItem.kt | 13 - .../LeverageBottomSheetDialogFragment.kt | 279 +++++++ .../android/ui/home/web3/trade/MarketItem.kt | 9 +- .../ui/home/web3/trade/OpenPositionItem.kt | 7 +- .../ui/home/web3/trade/OpenPositionPage.kt | 682 ++++++++---------- .../ui/home/web3/trade/PerpetualContent.kt | 9 +- 6 files changed, 608 insertions(+), 391 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index dc0b217c08..c1244f419f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -90,19 +90,6 @@ fun ClosedPositionItem(position: PerpsPositionHistoryItem) { fontWeight = FontWeight.SemiBold, color = MixinAppTheme.colors.textPrimary ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "${position.leverage}x", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .cardBackground( - MixinAppTheme.colors.backgroundWindow, - Color.Transparent - ) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) } Spacer(modifier = Modifier.height(4.dp)) Text( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..3f9c622c47 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt @@ -0,0 +1,279 @@ +package one.mixin.android.ui.home.web3.trade + +import android.annotation.SuppressLint +import android.app.Dialog +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Slider +import androidx.compose.material.SliderDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +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.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.putInt +import one.mixin.android.ui.common.MixinBottomSheetDialogFragment +import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.widget.BottomSheet +import java.math.BigDecimal +import kotlin.math.abs + +class LeverageBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { + + companion object { + const val TAG = "LeverageBottomSheetDialogFragment" + private const val PREF_LEVERAGE = "pref_perps_leverage" + + fun newInstance( + currentLeverage: Float, + maxLeverage: Int, + amount: String, + isLong: Boolean + ): LeverageBottomSheetDialogFragment { + return LeverageBottomSheetDialogFragment().apply { + this.currentLeverage = currentLeverage + this.maxLeverage = maxLeverage + this.amount = amount + this.isLong = isLong + } + } + } + + private var currentLeverage: Float = 10f + private var maxLeverage: Int = 100 + private var amount: String = "" + private var isLong: Boolean = true + private var onLeverageSelected: ((Float) -> Unit)? = null + + fun setOnLeverageSelected(callback: (Float) -> Unit): LeverageBottomSheetDialogFragment { + onLeverageSelected = callback + return this + } + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, style) + contentView = ComposeView(requireContext()).apply { + setContent { + MixinAppTheme { + LeverageContent( + currentLeverage = currentLeverage, + maxLeverage = maxLeverage, + amount = amount, + isLong = isLong, + onCancel = { dismiss() }, + onApply = { leverage -> + requireContext().defaultSharedPreferences.putInt(PREF_LEVERAGE, leverage.toInt()) + onLeverageSelected?.invoke(leverage) + dismiss() + } + ) + } + } + } + (dialog as BottomSheet).setCustomView(contentView) + } +} + +@Composable +private fun LeverageContent( + currentLeverage: Float, + maxLeverage: Int, + amount: String, + isLong: Boolean, + onCancel: () -> Unit, + onApply: (Float) -> Unit +) { + var tempLeverage by remember { mutableFloatStateOf(currentLeverage) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = stringResource(R.string.Leverage), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = "${tempLeverage.toInt()}x", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Slider( + value = tempLeverage, + onValueChange = { tempLeverage = it }, + valueRange = 1f..maxLeverage.toFloat(), + steps = maxLeverage - 2, + colors = SliderDefaults.colors( + thumbColor = MixinAppTheme.colors.accent, + activeTrackColor = MixinAppTheme.colors.accent, + inactiveTrackColor = MixinAppTheme.colors.backgroundWindow + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val steps = 5 + val stepValue = maxLeverage / (steps - 1) + for (i in 0 until steps) { + val value = if (i == steps - 1) maxLeverage else (i * stepValue) + Text( + text = if (i == steps - 1) "Max" else "${value}x", + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + val profitInfo = calculateProfitLossInfo( + amount = amount, + leverage = tempLeverage, + isLong = isLong + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + Text( + text = profitInfo.first, + fontSize = 13.sp, + color = MixinAppTheme.colors.walletGreen + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = profitInfo.second, + fontSize = 13.sp, + color = MixinAppTheme.colors.walletRed + ) + } + + Spacer(modifier = Modifier.height(28.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(48.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MixinAppTheme.colors.backgroundWindow) + .clickable { onCancel() }, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.Cancel), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + } + + Box( + modifier = Modifier + .weight(1f) + .height(48.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MixinAppTheme.colors.accent) + .clickable { onApply(tempLeverage) }, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.Apply), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +private fun calculateProfitLossInfo( + amount: String, + leverage: Float, + isLong: Boolean +): Pair { + val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO + + if (amountValue == BigDecimal.ZERO) { + return Pair( + "价格上涨 1% → 盈利 0%(+$0)", + "价格下跌 ${String.format("%.2f", 100.0 / leverage)}% → 亏损 -$0" + ) + } + + val priceUpPercent = 1.0 + val profitPercent = priceUpPercent * leverage + val profitAmount = amountValue * BigDecimal(profitPercent / 100) + + val liquidationPercent = 100.0 / leverage + val lossAmount = amountValue + + val profitText = if (isLong) { + "价格上涨 ${String.format("%.0f", abs(priceUpPercent))}% → 盈利 ${String.format("%.0f", profitPercent)}%(+$${String.format("%.2f", profitAmount)})" + } else { + "价格下跌 ${String.format("%.0f", abs(priceUpPercent))}% → 盈利 ${String.format("%.0f", profitPercent)}%(+$${String.format("%.2f", profitAmount)})" + } + + val lossText = if (isLong) { + "价格下跌 ${String.format("%.2f", liquidationPercent)}% → 亏损 -$${String.format("%.2f", lossAmount)}" + } else { + "价格上涨 ${String.format("%.2f", liquidationPercent)}% → 亏损 -$${String.format("%.2f", lossAmount)}" + } + + return Pair(profitText, lossText) +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt index 79213ceda5..14b74e8995 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt @@ -68,9 +68,7 @@ fun MarketItem( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - .padding(12.dp), + .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -82,9 +80,8 @@ fun MarketItem( model = market.iconUrl, placeholder = R.drawable.ic_avatar_place_holder, modifier = Modifier - .size(40.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop + .size(42.dp) + .clip(CircleShape) ) Spacer(modifier = Modifier.width(12.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt index 3bd130b1f9..ea6486b2c6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt @@ -86,8 +86,11 @@ fun OpenPositionItem(position: PerpsPositionItem) { color = MixinAppTheme.colors.textAssist, modifier = Modifier .clip(RoundedCornerShape(4.dp)) - .cardBackground(MixinAppTheme.colors.backgroundWindow, Color.Transparent) - .padding(horizontal = 6.dp, vertical = 2.dp) + .cardBackground( + MixinAppTheme.colors.backgroundGrayLight, + Color.Transparent + ) + .padding(horizontal = 3.dp, vertical = 1.dp) ) } Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index f85d5e62a9..fba9ddffa0 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -57,6 +58,8 @@ import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.putInt import one.mixin.android.extension.numberFormat8 import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground @@ -64,6 +67,8 @@ import one.mixin.android.vo.safe.TokenItem import java.math.BigDecimal import kotlin.math.abs +private const val PREF_LEVERAGE = "pref_perps_leverage" + @OptIn(ExperimentalMaterialApi::class) @Composable fun OpenPositionPage( @@ -76,23 +81,20 @@ fun OpenPositionPage( ) { val context = LocalContext.current val viewModel = hiltViewModel() - val coroutineScope = rememberCoroutineScope() - val bottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) var market by remember { mutableStateOf(null) } var selectedToken by remember { mutableStateOf(null) } var availableTokens by remember { mutableStateOf>(emptyList()) } var usdtAmount by remember { mutableStateOf("") } - var leverage by remember { mutableFloatStateOf(10f) } + + val savedLeverage = context.defaultSharedPreferences.getInt(PREF_LEVERAGE, 10) + var leverage by remember { mutableFloatStateOf(savedLeverage.toFloat()) } LaunchedEffect(marketId) { viewModel.loadMarketDetail( marketId = marketId, onSuccess = { data -> market = data - if (data.leverage > 0) { - leverage = minOf(10f, data.leverage.toFloat()) - } }, onError = {} ) @@ -106,418 +108,359 @@ fun OpenPositionPage( val maxLeverage = market?.leverage ?: 100 val leverageOptions = generateLeverageOptions(maxLeverage) - ModalBottomSheetLayout( - sheetState = bottomSheetState, - sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - sheetBackgroundColor = MixinAppTheme.colors.background, - sheetContent = { - LeverageBottomSheet( - currentLeverage = leverage, - maxLeverage = maxLeverage, - onLeverageChange = { - leverage = it - coroutineScope.launch { bottomSheetState.hide() } - } - ) - } - ) { - MixinAppTheme { - PageScaffold( - title = stringResource(R.string.Open_Position), - verticalScrollable = false, - pop = onBack + MixinAppTheme { + PageScaffold( + title = stringResource(R.string.Open_Position), + verticalScrollable = false, + pop = onBack + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) ) { + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + market?.let { m -> + CoilImage( + model = m.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${m.tokenSymbol}", + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = "${stringResource(R.string.Current_price, "${m.markPrice} USD")} ", + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Column( modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.Amount), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + InputContent( + token = selectedToken?.toSwapToken(), + text = usdtAmount, + selectClick = { + onTokenSelect() + }, + onInputChanged = { usdtAmount = it } + ) + + Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - market?.let { m -> - CoilImage( - model = m.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - text = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${m.tokenSymbol}", - fontSize = 16.sp, - color = MixinAppTheme.colors.textPrimary - ) - Text( - text = "${stringResource(R.string.Current_price, "${m.markPrice} USD")} ", - fontSize = 13.sp, - color = MixinAppTheme.colors.textAssist - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) - .padding(16.dp) - ) { + Icon( + painter = painterResource(id = R.drawable.ic_web3_wallet), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(R.string.Amount), - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary + text = selectedToken?.balance?.numberFormat8() ?: "0", + style = TextStyle( + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + textAlign = TextAlign.Start, + ), + modifier = Modifier.clickable { + usdtAmount = selectedToken?.balance ?: "0" + } ) + } + } + Spacer(modifier = Modifier.height(2.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { - Spacer(modifier = Modifier.height(8.dp)) - InputContent( - token = selectedToken?.toSwapToken(), - text = usdtAmount, - selectClick = { - onTokenSelect() - }, - onInputChanged = { usdtAmount = it } - ) + Text( + text = stringResource(R.string.Leverage), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(id = R.drawable.ic_web3_wallet), - contentDescription = null, - tint = MixinAppTheme.colors.textAssist, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = selectedToken?.balance?.numberFormat8() ?: "0", - style = TextStyle( - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist, - textAlign = TextAlign.Start, - ), - modifier = Modifier.clickable { - usdtAmount = selectedToken?.balance ?: "0" - } - ) - } - } - Spacer(modifier = Modifier.height(2.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) - .padding(16.dp) - ) { + Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(R.string.Leverage), - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary - ) + Text( + modifier = Modifier.clickable { + val activity = context as? FragmentActivity ?: return@clickable + LeverageBottomSheetDialogFragment.newInstance( + currentLeverage = leverage, + maxLeverage = maxLeverage, + amount = usdtAmount, + isLong = isLong + ).setOnLeverageSelected { newLeverage -> + leverage = newLeverage + }.show(activity.supportFragmentManager, LeverageBottomSheetDialogFragment.TAG) + }, + text = "${leverage.toInt()}x", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(12.dp)) - Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + leverageOptions.forEach { lev -> + val isSelected = if (lev == -1) { + !leverageOptions.dropLast(1).contains(leverage.toInt()) + } else if (lev == maxLeverage) { + leverage.toInt() == maxLeverage + } else { + leverage.toInt() == lev + } - Text( - modifier = Modifier.clickable { - coroutineScope.launch { bottomSheetState.show() } - }, - text = "${leverage.toInt()}x", - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = MixinAppTheme.colors.textPrimary - ) + val displayText = when (lev) { + -1 -> "Custom" + maxLeverage -> "Max" + else -> "${lev}x" + } - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - leverageOptions.forEach { lev -> - val isSelected = leverage.toInt() == lev - Box( - modifier = Modifier - .weight(1f) - .height(32.dp) - .clip(RoundedCornerShape(16.dp)) - .background(Color.Transparent) - .border( - width = 1.dp, - color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textAssist, - shape = RoundedCornerShape(16.dp) - ) - .clickable { leverage = lev.toFloat() }, - contentAlignment = Alignment.Center - ) { - Text( - text = "${lev}x", - fontSize = 12.sp, - color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textPrimary + Box( + modifier = Modifier + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent) + .border( + width = 1.dp, + color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textAssist, + shape = RoundedCornerShape(16.dp) ) - } + .clickable { + if (lev == -1) { + val activity = context as? FragmentActivity ?: return@clickable + LeverageBottomSheetDialogFragment.newInstance( + currentLeverage = leverage, + maxLeverage = maxLeverage, + amount = usdtAmount, + isLong = isLong + ).setOnLeverageSelected { newLeverage -> + leverage = newLeverage + }.show(activity.supportFragmentManager, LeverageBottomSheetDialogFragment.TAG) + } else { + leverage = lev.toFloat() + context.defaultSharedPreferences.putInt(PREF_LEVERAGE, lev) + } + }, + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.padding(horizontal = 10.dp), + text = displayText, + fontSize = 12.sp, + color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textPrimary + ) } } - Spacer(modifier = Modifier.height(12.dp)) - - val profitInfo = calculateProfitInfo( - amount = usdtAmount, - leverage = leverage, - isLong = isLong, - priceChangePercent = 1.0 - ) - - Text( - text = profitInfo, - fontSize = 13.sp, - color = MixinAppTheme.colors.textAssist, - modifier = Modifier.padding(horizontal = 4.dp) - ) - } + Spacer(modifier = Modifier.height(12.dp)) + + val profitInfo = calculateProfitInfo( + amount = usdtAmount, + leverage = leverage, + isLong = isLong, + priceChangePercent = 1.0 + ) + + Text( + text = profitInfo, + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.padding(horizontal = 4.dp) + ) - Spacer(modifier = Modifier.height(16.dp)) + } + Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - ) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = stringResource(R.string.Order_Value), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${calculateOrderValue(usdtAmount, leverage, market?.markPrice ?: "0")} ${market?.tokenSymbol}", - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist - ) - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = stringResource(R.string.Liquidation_Price), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = calculateLiquidationPrice( - market?.markPrice ?: "0", - leverage, - isLong - ), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist - ) - } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = stringResource(R.string.Order_Value), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${calculateOrderValue(usdtAmount, leverage, market?.markPrice ?: "0")} ${market?.tokenSymbol}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) } - Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.height(16.dp)) - - Button( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth() - .height(48.dp), - onClick = { - val token = selectedToken ?: return@Button - val amount = usdtAmount.toBigDecimalOrNull() ?: return@Button - - if (amount <= BigDecimal.ZERO) return@Button - - val m = market ?: return@Button - val walletId = Session.getAccountId() ?: "" // Privacy Wallet - if (walletId.isEmpty()) return@Button - - val activity = context as? FragmentActivity ?: return@Button - - val price = m.markPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - if (price == BigDecimal.ZERO) return@Button - - val orderValue = amount * BigDecimal(leverage.toDouble()) - - viewModel.openPerpsOrder( - assetId = token.assetId, - productId = marketId, - side = if (isLong) "long" else "short", - amount = orderValue.stripTrailingZeros().toPlainString(), - leverage = leverage.toInt(), - walletId = walletId, - marketSymbol = marketSymbol, - entryPrice = m.markPrice, - onSuccess = { response -> - PerpsConfirmBottomSheetDialogFragment.newInstance( - marketSymbol = m.displaySymbol, - marketIcon = m.iconUrl, - isLong = isLong, - amount = response.payAmount ?: "", - leverage = leverage.toInt(), - entryPrice = m.markPrice, - tokenSymbol = token.symbol, - payUrl = response.payUrl - ).setOnDone { - onBack() - }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) - }, - onError = { error -> - // TODO: Show error toast or dialog - } - ) - }, - enabled = usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO, - colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { - MixinAppTheme.colors.accent - } else { - MixinAppTheme.colors.backgroundGrayLight - } - ), - shape = RoundedCornerShape(32.dp), - elevation = ButtonDefaults.elevation( - pressedElevation = 0.dp, - defaultElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = stringResource(R.string.Liquidation_Price), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist ) - ) { + Spacer(modifier = Modifier.height(4.dp)) Text( - text = stringResource(R.string.Review), - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { - Color.White - } else { - MixinAppTheme.colors.textAssist - } + text = calculateLiquidationPrice( + market?.markPrice ?: "0", + leverage, + isLong + ), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist ) } - - Spacer(modifier = Modifier.height(24.dp)) } - } - } - } -} - -@Composable -private fun LeverageBottomSheet( - currentLeverage: Float, - maxLeverage: Int, - onLeverageChange: (Float) -> Unit, -) { - var tempLeverage by remember { mutableFloatStateOf(currentLeverage) } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = stringResource(R.string.Select_Leverage), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = MixinAppTheme.colors.textPrimary - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = "${tempLeverage.toInt()}x", - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - color = MixinAppTheme.colors.textPrimary, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - Spacer(modifier = Modifier.height(24.dp)) - - Slider( - value = tempLeverage, - onValueChange = { tempLeverage = it }, - valueRange = 1f..maxLeverage.toFloat(), - steps = maxLeverage - 2, - colors = SliderDefaults.colors( - thumbColor = MixinAppTheme.colors.accent, - activeTrackColor = MixinAppTheme.colors.accent, - inactiveTrackColor = MixinAppTheme.colors.backgroundWindow - ), - modifier = Modifier.fillMaxWidth() - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "1x", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - Text( - text = "${maxLeverage}x", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - } + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(24.dp)) + Button( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .height(48.dp), + onClick = { + val token = selectedToken ?: return@Button + val amount = usdtAmount.toBigDecimalOrNull() ?: return@Button + + if (amount <= BigDecimal.ZERO) return@Button + + val m = market ?: return@Button + val walletId = Session.getAccountId() ?: "" // Privacy Wallet + if (walletId.isEmpty()) return@Button + + val activity = context as? FragmentActivity ?: return@Button + + val price = m.markPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (price == BigDecimal.ZERO) return@Button + + val orderValue = amount * BigDecimal(leverage.toDouble()) + + viewModel.openPerpsOrder( + assetId = token.assetId, + productId = marketId, + side = if (isLong) "long" else "short", + amount = orderValue.stripTrailingZeros().toPlainString(), + leverage = leverage.toInt(), + walletId = walletId, + marketSymbol = marketSymbol, + entryPrice = m.markPrice, + onSuccess = { response -> + PerpsConfirmBottomSheetDialogFragment.newInstance( + marketSymbol = m.displaySymbol, + marketIcon = m.iconUrl, + isLong = isLong, + amount = response.payAmount ?: "", + leverage = leverage.toInt(), + entryPrice = m.markPrice, + tokenSymbol = token.symbol, + payUrl = response.payUrl + ).setOnDone { + onBack() + }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + }, + onError = { error -> + // TODO: Show error toast or dialog + } + ) + }, + enabled = usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { + MixinAppTheme.colors.accent + } else { + MixinAppTheme.colors.backgroundGrayLight + } + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp + ) + ) { + Text( + text = stringResource(R.string.Review), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { + Color.White + } else { + MixinAppTheme.colors.textAssist + } + ) + } - Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MixinAppTheme.colors.accent) - .clickable { onLeverageChange(tempLeverage) }, - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.Confirm), - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) + Spacer(modifier = Modifier.height(24.dp)) + } } - - Spacer(modifier = Modifier.height(16.dp)) } + } private fun generateLeverageOptions(maxLeverage: Int): List { val options = mutableListOf() - val baseOptions = listOf(1, 2, 5, 10, 20, 100) + val baseOptions = listOf(1, 2, 5, 10, 20) baseOptions.forEach { option -> - if (option <= maxLeverage) { + if (option < maxLeverage) { options.add(option) } } + if (options.size < 5) { + options.add(maxLeverage) + } + + options.add(-1) + return options.take(7) } @@ -571,13 +514,14 @@ private fun calculateOrderValue(amount: String, leverage: Float, price: String): return result } + private fun calculateLiquidationPrice( currentPrice: String, leverage: Float, isLong: Boolean, ): String { val price = currentPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - + if (price == BigDecimal.ZERO) { return "$0" diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index e2cad63996..0bd0996509 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -165,13 +165,20 @@ fun PerpetualContent( ) { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = stringResource(R.string.Open_Positions, openPositionsCount), fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + tint = MixinAppTheme.colors.textAssist, + modifier = Modifier.size(16.dp) + ) } if (openPositionsCount == 0) { Spacer(modifier = Modifier.height(16.dp)) From fff415d6870b588b32f20183b058fb47e08df5d9 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 3 Mar 2026 11:08:16 +0800 Subject: [PATCH 020/105] Update position item --- .../LeverageBottomSheetDialogFragment.kt | 84 ++++++++++++++----- .../ui/home/web3/trade/OpenPositionPage.kt | 3 +- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt index 3f9c622c47..806437a82c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt @@ -2,6 +2,9 @@ package one.mixin.android.ui.home.web3.trade import android.annotation.SuppressLint import android.app.Dialog +import android.view.Gravity +import android.view.View +import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -25,22 +28,27 @@ 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.platform.ComposeView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.R import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode import one.mixin.android.extension.putInt -import one.mixin.android.ui.common.MixinBottomSheetDialogFragment +import one.mixin.android.extension.screenHeight +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment import one.mixin.android.ui.wallet.alert.components.cardBackground -import one.mixin.android.widget.BottomSheet +import one.mixin.android.util.SystemUIManager import java.math.BigDecimal import kotlin.math.abs -class LeverageBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { +@AndroidEntryPoint +class LeverageBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { companion object { const val TAG = "LeverageBottomSheetDialogFragment" @@ -72,28 +80,58 @@ class LeverageBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { return this } + override fun getTheme() = R.style.AppTheme_Dialog + + @Composable + override fun ComposeContent() { + MixinAppTheme { + LeverageContent( + currentLeverage = currentLeverage, + maxLeverage = maxLeverage, + amount = amount, + isLong = isLong, + onCancel = { dismiss() }, + onApply = { leverage -> + requireContext().defaultSharedPreferences.putInt(PREF_LEVERAGE, leverage.toInt()) + onLeverageSelected?.invoke(leverage) + dismiss() + } + ) + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + override fun showError(error: String) { + } + @SuppressLint("RestrictedApi") override fun setupDialog(dialog: Dialog, style: Int) { - super.setupDialog(dialog, style) - contentView = ComposeView(requireContext()).apply { - setContent { - MixinAppTheme { - LeverageContent( - currentLeverage = currentLeverage, - maxLeverage = maxLeverage, - amount = amount, - isLong = isLong, - onCancel = { dismiss() }, - onApply = { leverage -> - requireContext().defaultSharedPreferences.putInt(PREF_LEVERAGE, leverage.toInt()) - onLeverageSelected?.invoke(leverage) - dismiss() - } - ) - } - } + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) } - (dialog as BottomSheet).setCustomView(contentView) + } + + override fun dismiss() { + dismissAllowingStateLoss() } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index fba9ddffa0..216dd1f25b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -290,7 +291,7 @@ fun OpenPositionPage( contentAlignment = Alignment.Center ) { Text( - modifier = Modifier.padding(horizontal = 10.dp), + modifier = Modifier.padding(horizontal = 10.dp).widthIn(min = 16.dp), text = displayText, fontSize = 12.sp, color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textPrimary From 64fe24c61f3d39b451dc372420c71b44c7d1253e Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 3 Mar 2026 11:40:38 +0800 Subject: [PATCH 021/105] View all --- .../android/db/perps/PerpsPositionDao.kt | 3 + .../home/web3/trade/AllPositionsFragment.kt | 21 +++++- .../ui/home/web3/trade/ClosedPositionItem.kt | 13 ++-- .../android/ui/home/web3/trade/MarketItem.kt | 3 +- .../ui/home/web3/trade/OpenPositionItem.kt | 13 ++-- .../ui/home/web3/trade/PerpetualContent.kt | 75 +++++++++++++++---- .../ui/home/web3/trade/PerpetualViewModel.kt | 24 +++--- .../PerpsCloseBottomSheetDialogFragment.kt | 1 + .../ui/home/web3/trade/TradeFragment.kt | 17 ++++- .../android/ui/home/web3/trade/TradePage.kt | 13 +++- 10 files changed, 143 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index 696c037a8d..3a2b914c3b 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -54,4 +54,7 @@ interface PerpsPositionDao : BaseDao { @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM positions WHERE wallet_id = :walletId AND state = 'open'") suspend fun getTotalOpenPositionValue(walletId: String): Double? + + @Query("DELETE FROM positions WHERE position_id = :positionId") + fun deleteById(positionId: String) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt index 264ffa680b..7fe8beea12 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt @@ -26,8 +26,19 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions companion object { const val TAG = "AllPositionsFragment" + private const val ARGS_INITIAL_TAB = "args_initial_tab" + private const val TAB_OPEN = "tab_open" + private const val TAB_CLOSED = "tab_closed" - fun newInstance() = AllPositionsFragment() + fun newInstance(initialOpenTab: Boolean = false) = AllPositionsFragment().apply { + arguments = Bundle().apply { + putString(ARGS_INITIAL_TAB, if (initialOpenTab) TAB_OPEN else TAB_CLOSED) + } + } + + fun newOpenInstance() = newInstance(initialOpenTab = true) + + fun newClosedInstance() = newInstance(initialOpenTab = false) } private val binding by viewBinding(FragmentAllClosedPositionsBinding::bind) @@ -101,8 +112,12 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions loadPositions() } - radioClosed.isChecked = true - loadPositions() + val initialTab = arguments?.getString(ARGS_INITIAL_TAB, TAB_CLOSED) + if (initialTab == TAB_OPEN) { + radioOpen.isChecked = true + } else { + radioClosed.isChecked = true + } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index c1244f419f..33a287b93d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3.trade +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,7 +30,10 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal @Composable -fun ClosedPositionItem(position: PerpsPositionHistoryItem) { +fun ClosedPositionItem( + position: PerpsPositionHistoryItem, + onClick: () -> Unit = {}, +) { val pnl = try { BigDecimal(position.realizedPnl) } catch (e: Exception) { @@ -50,6 +54,7 @@ fun ClosedPositionItem(position: PerpsPositionHistoryItem) { Row( modifier = Modifier .fillMaxWidth() + .clickable(onClick = onClick) .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically @@ -80,15 +85,13 @@ fun ClosedPositionItem(position: PerpsPositionHistoryItem) { Text( text = sideText, fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = if (position.side.lowercase() == "long") Color(0xFF4CAF50) else Color(0xFFF44336) + color = MixinAppTheme.colors.textPrimary, ) Spacer(modifier = Modifier.width(6.dp)) Text( text = displaySymbol, fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary + color = MixinAppTheme.colors.textPrimary, ) } Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt index 14b74e8995..2d3b135b05 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt @@ -68,6 +68,7 @@ fun MarketItem( Row( modifier = Modifier .fillMaxWidth() + .clickable(onClick = onClick) .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically @@ -92,7 +93,7 @@ fun MarketItem( ) { Text( text = market.displaySymbol, - fontSize = 16.sp, + fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) Spacer(modifier = Modifier.width(6.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt index ea6486b2c6..b51082b1c1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3.trade +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -29,7 +30,10 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal @Composable -fun OpenPositionItem(position: PerpsPositionItem) { +fun OpenPositionItem( + position: PerpsPositionItem, + onClick: () -> Unit = {}, +) { val pnl = position.unrealizedPnl?.toBigDecimalOrNull() ?: BigDecimal.ZERO val isProfit = pnl >= BigDecimal.ZERO val pnlColor = if (isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) @@ -40,6 +44,7 @@ fun OpenPositionItem(position: PerpsPositionItem) { Row( modifier = Modifier .fillMaxWidth() + .clickable(onClick = onClick) .padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically @@ -65,18 +70,15 @@ fun OpenPositionItem(position: PerpsPositionItem) { } else { stringResource(R.string.Short) } - val sideColor = if (position.side.equals("long", true)) Color(0xFF4CAF50) else Color(0xFFF44336) Text( text = sideText, fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = sideColor + color = MixinAppTheme.colors.textPrimary, ) Spacer(modifier = Modifier.width(6.dp)) Text( text = displaySymbol, fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.width(6.dp)) @@ -125,4 +127,3 @@ fun OpenPositionItem(position: PerpsPositionItem) { } } } - diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index 0bd0996509..f9d798fbfd 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -32,7 +32,6 @@ 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -52,10 +51,13 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground fun PerpetualContent( onShowTradingGuide: () -> Unit, onShowMarketList: (isLong: Boolean) -> Unit, + onShowAllOpenPositions: () -> Unit, onShowAllClosedPositions: () -> Unit, + onOpenPositionClick: (PerpsPositionItem) -> Unit, + onMarketItemClick: (PerpsMarket) -> Unit, + onClosedPositionClick: (PerpsPositionHistoryItem) -> Unit, ) { val walletId = Session.getAccountId()!! - val context = LocalContext.current val viewModel = hiltViewModel() var markets by remember { mutableStateOf>(emptyList()) } @@ -66,6 +68,9 @@ fun PerpetualContent( var totalPnl by remember { mutableStateOf(0.0) } var closedPositions by remember { mutableStateOf>(emptyList()) } var isLoadingHistory by remember { mutableStateOf(false) } + val openPositionsPreview = openPositions.take(3) + val marketsPreview = markets.take(3) + val closedPositionsPreview = closedPositions.take(3) LaunchedEffect(Unit) { // Refresh positions from API @@ -164,7 +169,9 @@ fun PerpetualContent( .padding(16.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onShowAllOpenPositions), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -218,10 +225,14 @@ fun PerpetualContent( ) } } else { - openPositions.forEach { position -> - OpenPositionItem(position = position) + openPositionsPreview.forEach { position -> + OpenPositionItem(position = position, onClick = { onOpenPositionClick(position) }) Spacer(modifier = Modifier.height(12.dp)) } + + if (openPositionsCount > openPositionsPreview.size) { + ViewAllAction(onClick = onShowAllOpenPositions) + } } } @@ -238,7 +249,9 @@ fun PerpetualContent( // Markets Section Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable { onShowMarketList(true) }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -283,15 +296,21 @@ fun PerpetualContent( ) } } else { - markets.take(2).forEach { market -> + marketsPreview.forEach { market -> MarketItem( market = market, onClick = { - PerpsActivity.showDetail(context, market.marketId, market.symbol, market.displaySymbol) + onMarketItemClick(market) } ) Spacer(modifier = Modifier.height(12.dp)) } + + if (markets.size > marketsPreview.size) { + ViewAllAction( + onClick = { onShowMarketList(true) } + ) + } } } @@ -303,13 +322,12 @@ fun PerpetualContent( .wrapContentHeight() .clip(RoundedCornerShape(8.dp)) .cardBackground(Color.Transparent, MixinAppTheme.colors.borderColor) - .clickable { - onShowAllClosedPositions() - } .padding(16.dp) ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onShowAllClosedPositions), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -363,10 +381,14 @@ fun PerpetualContent( } } } else { - closedPositions.forEach { position -> - ClosedPositionItem(position = position) + closedPositionsPreview.forEach { position -> + ClosedPositionItem(position = position, onClick = { onClosedPositionClick(position) }) Spacer(modifier = Modifier.height(12.dp)) } + + if (closedPositions.size > closedPositionsPreview.size) { + ViewAllAction(onClick = onShowAllClosedPositions) + } } } @@ -423,3 +445,28 @@ fun PerpetualContent( Spacer(modifier = Modifier.height(24.dp)) } } + +@Composable +private fun ViewAllAction(onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.view_all), + fontSize = 13.sp, + color = MixinAppTheme.colors.accent, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + tint = MixinAppTheme.colors.accent, + modifier = Modifier.size(14.dp) + ) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt index f84f516906..1b50558007 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt @@ -399,15 +399,6 @@ class PerpetualViewModel @Inject constructor( if (response.isSuccess) { Timber.d("Perps order closed: $positionId") - - withContext(Dispatchers.IO) { - perpsPositionDao.updateStatus( - positionId = positionId, - status = "closed", - updatedAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()) - ) - } - onSuccess() } else { val error = "Failed to close perps order: ${response.errorDescription}" @@ -422,6 +413,21 @@ class PerpetualViewModel @Inject constructor( } } + fun deletePosition(positionId: String) { + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + perpsPositionDao.deleteById( + positionId + ) + } + Timber.d("Position deleted: $positionId") + } catch (e: Exception) { + Timber.e(e, "Error deleting position: ${e.message}") + } + } + } + fun loadPositionDetail( positionId: String, onSuccess: (PerpsPosition) -> Unit, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt index c8423a6302..ee81bc47ec 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt @@ -555,6 +555,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen viewModel.closePerpsOrder( positionId = positionId, onSuccess = { + viewModel.deletePosition(positionId) step = Step.Done }, onError = { error -> 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 1524b99f69..3d59884dbf 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 @@ -42,6 +42,7 @@ import one.mixin.android.R import one.mixin.android.RxBus import one.mixin.android.api.request.web3.SwapRequest import one.mixin.android.api.response.CreateLimitOrderResponse +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.web3.QuoteResult import one.mixin.android.api.response.web3.SwapResponse import one.mixin.android.api.response.web3.SwapToken @@ -73,10 +74,12 @@ import one.mixin.android.ui.wallet.AllOrdersFragment import one.mixin.android.ui.wallet.DepositFragment import one.mixin.android.ui.wallet.LimitTransferBottomSheetDialogFragment import one.mixin.android.ui.wallet.SwapTransferBottomSheetDialogFragment +import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.util.ErrorHandler import one.mixin.android.util.GsonHelper import one.mixin.android.util.analytics.AnalyticsTracker +import one.mixin.android.vo.market.MarketItem import one.mixin.android.vo.safe.TokenItem import one.mixin.android.web3.Rpc import one.mixin.android.web3.js.Web3Signer @@ -336,8 +339,20 @@ class TradeFragment : BaseFragment() { onShowMarketList = { isLong -> MarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, MarketListBottomSheetDialogFragment.TAG) }, + onShowAllOpenPositions = { + navTo(AllPositionsFragment.newOpenInstance(), AllPositionsFragment.TAG) + }, onShowAllClosedPositions = { - navTo(AllPositionsFragment.newInstance(), AllPositionsFragment.TAG) + navTo(AllPositionsFragment.newClosedInstance(), AllPositionsFragment.TAG) + }, + onOpenPositionClick = { position -> + navTo(PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) + }, + onMarketItemClick = { market -> + PerpsActivity.showDetail(requireContext(), market.marketId, market.symbol, market.displaySymbol) + }, + onClosedPositionClick = { position -> + navTo(PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) } ) } 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 d2a6465c26..cebfa5fbed 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 @@ -51,6 +51,9 @@ import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.CreateLimitOrderResponse +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.web3.QuoteResult import one.mixin.android.api.response.web3.SwapToken import one.mixin.android.compose.theme.MixinAppTheme @@ -90,7 +93,11 @@ fun TradePage( onLimitOrderClick: (String) -> Unit, onShowTradingGuide: () -> Unit, onShowMarketList: (Boolean) -> Unit, + onShowAllOpenPositions: () -> Unit, onShowAllClosedPositions: () -> Unit, + onOpenPositionClick: (PerpsPositionItem) -> Unit, + onMarketItemClick: (PerpsMarket) -> Unit, + onClosedPositionClick: (PerpsPositionHistoryItem) -> Unit, ) { val context = LocalContext.current @@ -171,7 +178,11 @@ fun TradePage( PerpetualContent( onShowTradingGuide = onShowTradingGuide, onShowMarketList = onShowMarketList, - onShowAllClosedPositions = onShowAllClosedPositions + onShowAllOpenPositions = onShowAllOpenPositions, + onShowAllClosedPositions = onShowAllClosedPositions, + onOpenPositionClick = onOpenPositionClick, + onMarketItemClick = onMarketItemClick, + onClosedPositionClick = onClosedPositionClick, ) } } From feae921bd9a36f2018125a286bc4cae1b00a266d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 3 Mar 2026 13:27:11 +0800 Subject: [PATCH 022/105] All markets --- .../ui/home/web3/trade/AllMarketsFragment.kt | 192 ++++++++++++++++++ .../ui/home/web3/trade/PerpetualContent.kt | 5 +- .../ui/home/web3/trade/TradeFragment.kt | 3 + .../android/ui/home/web3/trade/TradePage.kt | 2 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 6 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/AllMarketsFragment.kt diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllMarketsFragment.kt new file mode 100644 index 0000000000..03edaad490 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllMarketsFragment.kt @@ -0,0 +1,192 @@ +package one.mixin.android.ui.home.web3.trade + +import PageScaffold +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsMarket +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.toast +import one.mixin.android.ui.common.BaseFragment +import one.mixin.android.ui.wallet.WalletActivity +import one.mixin.android.vo.market.MarketItem + +@AndroidEntryPoint +class AllMarketsFragment : BaseFragment() { + + companion object { + const val TAG = "AllMarketsFragment" + + fun newInstance() = AllMarketsFragment() + } + + private val swapViewModel by viewModels() + + override fun onCreateView( + inflater: android.view.LayoutInflater, + container: android.view.ViewGroup?, + savedInstanceState: android.os.Bundle?, + ): android.view.View { + return ComposeView(inflater.context).apply { + setContent { + MixinAppTheme(darkTheme = context.isNightMode()) { + AllMarketsPage( + pop = { activity?.onBackPressedDispatcher?.onBackPressed() }, + onMarketClick = { market -> + lifecycleScope.launch { + showMarketDetails(market) + } + } + ) + } + } + } + } + + private suspend fun showMarketDetails(market: PerpsMarket) { + val marketItem = findMarketItemByPerpsMarket(market) + if (marketItem != null && activity != null) { + WalletActivity.showWithMarket(requireActivity(), marketItem, WalletActivity.Destination.Market) + } else { + toast(R.string.Alert_Not_Support) + } + } + + private suspend fun findMarketItemByPerpsMarket(market: PerpsMarket): MarketItem? { + val symbols = linkedSetOf( + market.tokenSymbol, + market.displaySymbol.substringBefore("/"), + market.displaySymbol.substringBefore("-"), + market.symbol.substringBefore("-"), + market.symbol.substringBefore("_"), + ).map { it.trim().uppercase() }.filter { it.isNotEmpty() } + + for (symbol in symbols) { + val tokens = runCatching { swapViewModel.searchTokens(symbol, true) } + .getOrNull() + ?.data + .orEmpty() + val token = tokens.firstOrNull { it.symbol.equals(symbol, ignoreCase = true) } ?: tokens.firstOrNull() + val assetId = token?.assetId ?: continue + val marketItem = runCatching { swapViewModel.checkMarketById(assetId, false) }.getOrNull() + if (marketItem != null) { + return marketItem + } + } + return null + } +} + +@Composable +private fun AllMarketsPage( + pop: () -> Unit, + onMarketClick: (PerpsMarket) -> Unit, +) { + val viewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel() + var markets by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.loadMarkets( + onSuccess = { data -> + markets = data + isLoading = false + }, + onError = { error -> + errorMessage = error + isLoading = false + } + ) + } + + PageScaffold( + title = androidx.compose.ui.res.stringResource(R.string.Markets), + verticalScrollable = false, + pop = pop + ) { + when { + isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MixinAppTheme.colors.accent) + } + } + + errorMessage != null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = errorMessage ?: "", + fontSize = 14.sp, + color = MixinAppTheme.colors.red, + ) + } + } + + markets.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = androidx.compose.ui.res.stringResource(R.string.No_Markets), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Top + ) { + item { Spacer(modifier = Modifier.height(8.dp)) } + items(markets, key = { it.marketId }) { market -> + Column(modifier = Modifier.fillMaxWidth()) { + MarketItem( + market = market, + onClick = { onMarketClick(market) } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + item { Spacer(modifier = Modifier.height(24.dp)) } + } + } + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index f9d798fbfd..d716393dbd 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -51,6 +51,7 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground fun PerpetualContent( onShowTradingGuide: () -> Unit, onShowMarketList: (isLong: Boolean) -> Unit, + onShowAllMarkets: () -> Unit, onShowAllOpenPositions: () -> Unit, onShowAllClosedPositions: () -> Unit, onOpenPositionClick: (PerpsPositionItem) -> Unit, @@ -251,7 +252,7 @@ fun PerpetualContent( Row( modifier = Modifier .fillMaxWidth() - .clickable { onShowMarketList(true) }, + .clickable { onShowAllMarkets() }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -308,7 +309,7 @@ fun PerpetualContent( if (markets.size > marketsPreview.size) { ViewAllAction( - onClick = { onShowMarketList(true) } + onClick = onShowAllMarkets ) } } 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 3d59884dbf..0e00e2caba 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 @@ -339,6 +339,9 @@ class TradeFragment : BaseFragment() { onShowMarketList = { isLong -> MarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, MarketListBottomSheetDialogFragment.TAG) }, + onShowAllMarkets = { + navTo(AllMarketsFragment.newInstance(), AllMarketsFragment.TAG) + }, onShowAllOpenPositions = { navTo(AllPositionsFragment.newOpenInstance(), AllPositionsFragment.TAG) }, 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 cebfa5fbed..6441489e8d 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 @@ -93,6 +93,7 @@ fun TradePage( onLimitOrderClick: (String) -> Unit, onShowTradingGuide: () -> Unit, onShowMarketList: (Boolean) -> Unit, + onShowAllMarkets: () -> Unit, onShowAllOpenPositions: () -> Unit, onShowAllClosedPositions: () -> Unit, onOpenPositionClick: (PerpsPositionItem) -> Unit, @@ -178,6 +179,7 @@ fun TradePage( PerpetualContent( onShowTradingGuide = onShowTradingGuide, onShowMarketList = onShowMarketList, + onShowAllMarkets = onShowAllMarkets, onShowAllOpenPositions = onShowAllOpenPositions, onShowAllClosedPositions = onShowAllClosedPositions, onOpenPositionClick = onOpenPositionClick, diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e5c11379f1..3617591a1b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2311,6 +2311,8 @@ 已平仓 暂无持仓 暂无已平仓记录 + 暂无行情 + 查看更多 加载中... 成交量 %1$s 开仓: $%1$s → 平仓: $%2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a663b4ff97..afacdc7987 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2379,6 +2379,8 @@ Closed Positions No Positions No Closed Positions + No Markets + View More Loading... Vol %1$s Entry: $%1$s → Close: $%2$s From 815557575329d56b427640cfff6fc24cdfff018a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 3 Mar 2026 15:28:01 +0800 Subject: [PATCH 023/105] Update --- ...Fragment.kt => AllPerpsMarketsFragment.kt} | 14 +++--- .../ui/home/web3/trade/ClosedPositionItem.kt | 43 +++++++++---------- .../ui/home/web3/trade/OpenPositionItem.kt | 33 +++++++++----- .../ui/home/web3/trade/PositionDetailPage.kt | 7 +++ .../ui/home/web3/trade/TradeFragment.kt | 5 +-- 5 files changed, 59 insertions(+), 43 deletions(-) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{AllMarketsFragment.kt => AllPerpsMarketsFragment.kt} (95%) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt similarity index 95% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/AllMarketsFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt index 03edaad490..a21b119398 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt @@ -34,16 +34,15 @@ import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.isNightMode import one.mixin.android.extension.toast import one.mixin.android.ui.common.BaseFragment -import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.vo.market.MarketItem @AndroidEntryPoint -class AllMarketsFragment : BaseFragment() { +class AllPerpsMarketsFragment : BaseFragment() { companion object { - const val TAG = "AllMarketsFragment" + const val TAG = "AllPerpsMarketsFragment" - fun newInstance() = AllMarketsFragment() + fun newInstance() = AllPerpsMarketsFragment() } private val swapViewModel by viewModels() @@ -72,7 +71,12 @@ class AllMarketsFragment : BaseFragment() { private suspend fun showMarketDetails(market: PerpsMarket) { val marketItem = findMarketItemByPerpsMarket(market) if (marketItem != null && activity != null) { - WalletActivity.showWithMarket(requireActivity(), marketItem, WalletActivity.Destination.Market) + PerpsActivity.showDetail( + requireContext(), + market.marketId, + market.symbol, + market.displaySymbol + ) } else { toast(R.string.Alert_Not_Support) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index 33a287b93d..3e3b105f74 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -18,14 +18,17 @@ 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal @@ -34,6 +37,10 @@ fun ClosedPositionItem( position: PerpsPositionHistoryItem, onClick: () -> Unit = {}, ) { + val context = LocalContext.current + val quoteColorPref = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val pnl = try { BigDecimal(position.realizedPnl) } catch (e: Exception) { @@ -41,7 +48,19 @@ fun ClosedPositionItem( } val isProfit = pnl >= BigDecimal.ZERO - val pnlColor = if (isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) + val pnlColor = if (isProfit) { + if (quoteColorPref) { + MixinAppTheme.colors.walletRed + } else { + MixinAppTheme.colors.walletGreen + } + } else { + if (quoteColorPref) { + MixinAppTheme.colors.walletGreen + } else { + MixinAppTheme.colors.walletRed + } + } val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" val quantity = try { @@ -109,30 +128,8 @@ fun ClosedPositionItem( Text( text = String.format("$%.2f", pnl.abs()), fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, color = pnlColor ) - Spacer(modifier = Modifier.height(2.dp)) - val entryPrice = try { - BigDecimal(position.entryPrice) - } catch (e: Exception) { - BigDecimal.ZERO - } - val closePrice = try { - BigDecimal(position.closePrice) - } catch (e: Exception) { - BigDecimal.ZERO - } - val priceChange = if (entryPrice > BigDecimal.ZERO) { - ((closePrice - entryPrice) / entryPrice * BigDecimal(100)) - } else { - BigDecimal.ZERO - } - Text( - text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange), - fontSize = 12.sp, - color = if (priceChange >= BigDecimal.ZERO) Color(0xFF4CAF50) else Color(0xFFF44336) - ) } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt index b51082b1c1..d37ad3eb7b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt @@ -18,14 +18,17 @@ 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal @@ -34,9 +37,10 @@ fun OpenPositionItem( position: PerpsPositionItem, onClick: () -> Unit = {}, ) { + val context = LocalContext.current + val quoteColorPref = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) val pnl = position.unrealizedPnl?.toBigDecimalOrNull() ?: BigDecimal.ZERO - val isProfit = pnl >= BigDecimal.ZERO - val pnlColor = if (isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: stringResource(R.string.Unknown) val quantity = position.quantity.toBigDecimalOrNull()?.let { String.format("%.4f", it) } ?: position.quantity @@ -108,21 +112,28 @@ fun OpenPositionItem( Text( text = String.format("$%.2f", pnl.abs()), fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = pnlColor + color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(2.dp)) - val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO - val priceChange = if (entryPrice > BigDecimal.ZERO) { - ((markPrice - entryPrice) / entryPrice * BigDecimal(100)) + val unrealizedPnl = position.unrealizedPnl?.toBigDecimalOrNull()?: BigDecimal.ZERO + val isProfit = unrealizedPnl >= BigDecimal.ZERO + val pnlColor = if (isProfit) { + if (quoteColorPref) { + MixinAppTheme.colors.walletRed + } else { + MixinAppTheme.colors.walletGreen + } } else { - BigDecimal.ZERO + if (quoteColorPref) { + MixinAppTheme.colors.walletGreen + } else { + MixinAppTheme.colors.walletRed + } } Text( - text = String.format("%s%.1f%%", if (priceChange >= BigDecimal.ZERO) "+" else "", priceChange), + text = String.format("%s%.2f", if (unrealizedPnl >= BigDecimal.ZERO) "+" else "", unrealizedPnl), fontSize = 12.sp, - color = if (priceChange >= BigDecimal.ZERO) Color(0xFF4CAF50) else Color(0xFFF44336) + color = pnlColor ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt index f02a1f6a62..b3e5d6746b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt @@ -2,6 +2,7 @@ package one.mixin.android.ui.home.web3.trade import PageScaffold import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,19 +18,24 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +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.ui.wallet.alert.components.cardBackground @@ -42,6 +48,7 @@ fun PositionDetailPage( position: PerpsPositionItem, pop: () -> Unit, ) { + val context = LocalContext.current val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) fun formatDate(dateStr: String?): String { 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 0e00e2caba..1c56bc6f24 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 @@ -42,7 +42,6 @@ import one.mixin.android.R import one.mixin.android.RxBus import one.mixin.android.api.request.web3.SwapRequest import one.mixin.android.api.response.CreateLimitOrderResponse -import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.web3.QuoteResult import one.mixin.android.api.response.web3.SwapResponse import one.mixin.android.api.response.web3.SwapToken @@ -74,12 +73,10 @@ import one.mixin.android.ui.wallet.AllOrdersFragment import one.mixin.android.ui.wallet.DepositFragment import one.mixin.android.ui.wallet.LimitTransferBottomSheetDialogFragment import one.mixin.android.ui.wallet.SwapTransferBottomSheetDialogFragment -import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.ui.wallet.fiatmoney.requestRouteAPI import one.mixin.android.util.ErrorHandler import one.mixin.android.util.GsonHelper import one.mixin.android.util.analytics.AnalyticsTracker -import one.mixin.android.vo.market.MarketItem import one.mixin.android.vo.safe.TokenItem import one.mixin.android.web3.Rpc import one.mixin.android.web3.js.Web3Signer @@ -340,7 +337,7 @@ class TradeFragment : BaseFragment() { MarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, MarketListBottomSheetDialogFragment.TAG) }, onShowAllMarkets = { - navTo(AllMarketsFragment.newInstance(), AllMarketsFragment.TAG) + navTo(AllPerpsMarketsFragment.newInstance(), AllPerpsMarketsFragment.TAG) }, onShowAllOpenPositions = { navTo(AllPositionsFragment.newOpenInstance(), AllPositionsFragment.TAG) From 59360dfd01c648136170f30b567be5eb9f13a388 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 3 Mar 2026 16:29:27 +0800 Subject: [PATCH 024/105] Position share --- app/src/main/AndroidManifest.xml | 5 + .../home/web3/trade/AllPositionsFragment.kt | 6 +- .../ui/home/web3/trade/OpenPositionPage.kt | 7 +- .../web3/trade/PerpsPositionShareActivity.kt | 342 ++++++++++++++++++ .../home/web3/trade/PositionDetailFragment.kt | 33 ++ .../ui/home/web3/trade/PositionDetailPage.kt | 14 +- .../res/drawable-xxhdpi/ic_perps_loss.png | Bin 0 -> 81330 bytes .../res/drawable-xxhdpi/ic_perps_profit.png | Bin 0 -> 78036 bytes .../bg_perps_share_card_container.xml | 6 + .../res/drawable/bg_perps_share_card_loss.xml | 12 + .../drawable/bg_perps_share_card_profit.xml | 12 + .../res/drawable/bg_perps_share_footer.xml | 8 + .../main/res/drawable/bg_perps_share_tag.xml | 9 + .../main/res/drawable/ic_perps_share_loss.xml | 9 + .../res/drawable/ic_perps_share_profit.xml | 9 + .../layout/activity_perps_position_share.xml | 341 +++++++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 18 files changed, 806 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt create mode 100644 app/src/main/res/drawable-xxhdpi/ic_perps_loss.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_perps_profit.png create mode 100644 app/src/main/res/drawable/bg_perps_share_card_container.xml create mode 100644 app/src/main/res/drawable/bg_perps_share_card_loss.xml create mode 100644 app/src/main/res/drawable/bg_perps_share_card_profit.xml create mode 100644 app/src/main/res/drawable/bg_perps_share_footer.xml create mode 100644 app/src/main/res/drawable/bg_perps_share_tag.xml create mode 100644 app/src/main/res/drawable/ic_perps_share_loss.xml create mode 100644 app/src/main/res/drawable/ic_perps_share_profit.xml create mode 100644 app/src/main/res/layout/activity_perps_position_share.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 60058f880b..eef129b319 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -340,6 +340,11 @@ android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:theme="@style/AppTheme.Blur" android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> + >(emptyList()) } var usdtAmount by remember { mutableStateOf("") } - val savedLeverage = context.defaultSharedPreferences.getInt(PREF_LEVERAGE, 10) + val savedLeverage = context.defaultSharedPreferences.getInt(getLeveragePrefKey(marketId), 10) var leverage by remember { mutableFloatStateOf(savedLeverage.toFloat()) } LaunchedEffect(marketId) { @@ -282,10 +282,11 @@ fun OpenPositionPage( isLong = isLong ).setOnLeverageSelected { newLeverage -> leverage = newLeverage + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), newLeverage.toInt()) }.show(activity.supportFragmentManager, LeverageBottomSheetDialogFragment.TAG) } else { leverage = lev.toFloat() - context.defaultSharedPreferences.putInt(PREF_LEVERAGE, lev) + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), lev) } }, contentAlignment = Alignment.Center diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt new file mode 100644 index 0000000000..83fc6f3226 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt @@ -0,0 +1,342 @@ +package one.mixin.android.ui.home.web3.trade + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.RenderEffect +import android.graphics.Shader +import android.graphics.drawable.BitmapDrawable +import android.media.MediaScannerConnection +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import androidx.core.content.FileProvider +import androidx.core.view.drawToBitmap +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import one.mixin.android.BuildConfig +import one.mixin.android.R +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.databinding.ActivityPerpsPositionShareBinding +import one.mixin.android.extension.blurBitmap +import one.mixin.android.extension.dp +import one.mixin.android.extension.generateQRCode +import one.mixin.android.extension.getClipboardManager +import one.mixin.android.extension.getParcelableCompat +import one.mixin.android.extension.getPublicDownloadPath +import one.mixin.android.extension.loadImage +import one.mixin.android.extension.priceFormat +import one.mixin.android.extension.round +import one.mixin.android.extension.supportsS +import one.mixin.android.extension.toast +import one.mixin.android.session.Session +import one.mixin.android.ui.common.BaseActivity +import one.mixin.android.ui.web.getScreenshot +import one.mixin.android.ui.web.refreshScreenshot +import java.io.File +import java.io.FileOutputStream +import java.math.BigDecimal +import java.math.RoundingMode + +@AndroidEntryPoint +class PerpsPositionShareActivity : BaseActivity() { + companion object { + private const val ARGS_POSITION = "args_position" + private const val ARGS_POSITION_HISTORY = "args_position_history" + private const val SHARE_INSTALL_URL = "https://mixin.one/mm" + + fun show(context: Context, position: PerpsPositionItem) { + refreshScreenshot(context, 0x33000000) + context.startActivity(Intent(context, PerpsPositionShareActivity::class.java).apply { + putExtra(ARGS_POSITION, position) + }) + } + + fun show(context: Context, positionHistory: PerpsPositionHistoryItem) { + refreshScreenshot(context, 0x33000000) + context.startActivity(Intent(context, PerpsPositionShareActivity::class.java).apply { + putExtra(ARGS_POSITION_HISTORY, positionHistory) + }) + } + } + + override fun getNightThemeId(): Int = R.style.AppTheme_Night_Blur + + override fun getDefaultThemeId(): Int = R.style.AppTheme_Blur + + private lateinit var binding: ActivityPerpsPositionShareBinding + + private val position: PerpsPositionItem? by lazy { + intent.extras?.getParcelableCompat(ARGS_POSITION, PerpsPositionItem::class.java) + } + + private val positionHistory: PerpsPositionHistoryItem? by lazy { + intent.extras?.getParcelableCompat(ARGS_POSITION_HISTORY, PerpsPositionHistoryItem::class.java) + } + + private val shareLink: String by lazy { + val identity = Session.getAccount()?.identityNumber + if (identity.isNullOrEmpty()) { + SHARE_INSTALL_URL + } else { + "$SHARE_INSTALL_URL?ref=$identity" + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPerpsPositionShareBinding.inflate(layoutInflater) + setContentView(binding.root) + + getScreenshot()?.let { + supportsS({ + binding.overlay.background = BitmapDrawable(resources, it) + binding.overlay.setRenderEffect(RenderEffect.createBlurEffect(25f, 25f, Shader.TileMode.MIRROR)) + }, { + binding.container.background = BitmapDrawable(resources, it.blurBitmap(25)) + }) + } + + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + window.statusBarColor = android.graphics.Color.TRANSPARENT + + binding.content.updateLayoutParams { + topMargin = 20.dp + } + binding.iconFl.round(6.dp) + + val hasContent = bindContent() + if (!hasContent) { + finish() + return + } + bindFooter() + + binding.apply { + share.setOnClickListener { + onShare() + } + copy.setOnClickListener { + onCopy() + } + save.setOnClickListener { + onSave() + } + close.setOnClickListener { + onBackPressed() + } + container.setOnClickListener { + onBackPressed() + } + } + + applyFadeInAnimation(binding.root) + } + + private fun bindContent(): Boolean { + val open = position + if (open != null) { + val pnlAmount = open.unrealizedPnl.toBigDecimalSafely() ?: BigDecimal.ZERO + val pnlPercent = open.roe.toBigDecimalSafely()?.let { roe -> + if (roe.abs() <= BigDecimal.ONE) { + roe.multiply(BigDecimal(100)) + } else { + roe + } + } ?: calculateLeveragedPnlPercent( + entryPrice = open.entryPrice, + currentPrice = open.markPrice ?: open.entryPrice, + side = open.side, + leverage = open.leverage, + pnlAmount = pnlAmount, + ) + + bindCard( + iconUrl = open.iconUrl, + side = open.side, + leverage = open.leverage, + pnlPercent = alignSign(pnlPercent, pnlAmount), + isProfit = pnlAmount >= BigDecimal.ZERO, + tokenSymbol = open.tokenSymbol?:"", + entryPrice = open.entryPrice, + latestLabel = getString(R.string.Latest_Price), + latestPrice = open.markPrice ?: open.entryPrice, + ) + return true + } + + val closed = positionHistory ?: return false + val pnlAmount = closed.realizedPnl.toBigDecimalSafely() ?: BigDecimal.ZERO + val pnlPercent = calculateLeveragedPnlPercent( + entryPrice = closed.entryPrice, + currentPrice = closed.closePrice, + side = closed.side, + leverage = closed.leverage, + pnlAmount = pnlAmount, + ) + + bindCard( + iconUrl = closed.iconUrl, + side = closed.side, + leverage = closed.leverage, + pnlPercent = pnlPercent, + isProfit = pnlAmount >= BigDecimal.ZERO, + tokenSymbol = closed.tokenSymbol?:"", + entryPrice = closed.entryPrice, + latestLabel = getString(R.string.Close_Price), + latestPrice = closed.closePrice, + ) + return true + } + + private fun bindCard( + iconUrl: String?, + side: String, + leverage: Int, + pnlPercent: BigDecimal, + isProfit: Boolean, + tokenSymbol: String, + entryPrice: String, + latestLabel: String, + latestPrice: String, + ) { + binding.assetIcon.loadImage(iconUrl, R.drawable.ic_avatar_place_holder) + binding.pnlTv.text = formatSignedPercent(pnlPercent) + + val isLong = side.equals("long", ignoreCase = true) + binding.sideTagTv.text = "${if (isLong) getString(R.string.Long) else getString(R.string.Short)} $tokenSymbol".trim() + binding.leverageTagTv.text = getString(R.string.Perpetual_Leverage_Format, leverage) + + binding.topCard.setBackgroundResource( + if (isProfit) R.drawable.bg_perps_share_card_profit else R.drawable.bg_perps_share_card_loss + ) + binding.trendImage.setImageResource( + if (isProfit) R.drawable.ic_perps_profit else R.drawable.ic_perps_loss + ) + + binding.entryValueTv.text = formatUsd(entryPrice) + binding.latestLabelTv.text = latestLabel + binding.latestValueTv.text = formatUsd(latestPrice) + } + + private fun bindFooter() { + val qrCode = shareLink.generateQRCode(72.dp, 8.dp).first + binding.qr.setImageBitmap(qrCode) + } + + private fun applyFadeInAnimation(view: View) { + view.alpha = 0f + view.animate() + .alpha(1f) + .setDuration(500) + .setListener(null) + } + + private val onShare: () -> Unit = { + lifecycleScope.launch { + val bitmap = binding.topCard.drawToBitmap() + val file = File(cacheDir, "${buildFileName()}_position.png") + saveBitmapToFile(file, bitmap) + val uri = FileProvider.getUriForFile(this@PerpsPositionShareActivity, BuildConfig.APPLICATION_ID + ".provider", file) + val share = Intent() + share.action = Intent.ACTION_SEND + share.type = "image/png" + share.putExtra(Intent.EXTRA_STREAM, uri) + finish() + startActivity(Intent.createChooser(share, getString(R.string.Share))) + } + } + + private val onCopy: () -> Unit = { + getClipboardManager().setPrimaryClip(ClipData.newPlainText(null, shareLink)) + finish() + toast(R.string.copied_to_clipboard) + } + + private val onSave: () -> Unit = { + lifecycleScope.launch { + delay(100) + val bitmap = binding.topCard.drawToBitmap() + val dir = getPublicDownloadPath() + dir.mkdirs() + val file = File(dir, "${buildFileName()}_position.png") + saveBitmapToFile(file, bitmap) + MediaScannerConnection.scanFile(this@PerpsPositionShareActivity, arrayOf(file.toString()), null, null) + finish() + toast(getString(R.string.Save_to, dir.path)) + } + } + + private fun buildFileName(): String { + val name = position?.displaySymbol + ?: position?.tokenSymbol + ?: positionHistory?.displaySymbol + ?: positionHistory?.tokenSymbol + ?: "perps" + return name.replace("[^A-Za-z0-9._-]".toRegex(), "_") + } + + private fun calculateLeveragedPnlPercent( + entryPrice: String?, + currentPrice: String?, + side: String, + leverage: Int, + pnlAmount: BigDecimal, + ): BigDecimal { + val entry = entryPrice.toBigDecimalSafely() + val current = currentPrice.toBigDecimalSafely() + if (entry == null || current == null || entry <= BigDecimal.ZERO) { + return BigDecimal.ZERO + } + + val direction = if (side.equals("short", ignoreCase = true)) BigDecimal(-1) else BigDecimal.ONE + val changeRatio = current.subtract(entry).divide(entry, 8, RoundingMode.HALF_UP) + val computed = changeRatio + .multiply(BigDecimal(leverage)) + .multiply(BigDecimal(100)) + .multiply(direction) + + return alignSign(computed, pnlAmount) + } + + private fun alignSign(value: BigDecimal, pnlAmount: BigDecimal): BigDecimal { + return when { + pnlAmount > BigDecimal.ZERO && value < BigDecimal.ZERO -> value.negate() + pnlAmount < BigDecimal.ZERO && value > BigDecimal.ZERO -> value.negate() + else -> value + } + } + + private fun formatSignedPercent(value: BigDecimal): String { + val sign = when { + value > BigDecimal.ZERO -> "+" + value < BigDecimal.ZERO -> "-" + else -> "" + } + val number = value.abs().setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + return "$sign$number%" + } + + private fun formatUsd(value: String?): String { + val price = value.toBigDecimalSafely() ?: BigDecimal.ZERO + return "$${price.priceFormat()}" + } + + private fun String?.toBigDecimalSafely(): BigDecimal? { + return this?.toBigDecimalOrNull() + } + + private fun saveBitmapToFile(file: File, bitmap: Bitmap) { + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt index 7ae4f1bf9e..88be9b5874 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt @@ -12,6 +12,7 @@ import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.isNightMode import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.api.response.perps.toPosition import one.mixin.android.ui.common.BaseFragment @AndroidEntryPoint @@ -58,6 +59,12 @@ class PositionDetailFragment : BaseFragment() { position = position, pop = { activity?.onBackPressedDispatcher?.onBackPressed() + }, + onClose = { + showCloseDialog(position) + }, + onShare = { + PerpsPositionShareActivity.show(requireContext(), position) } ) } else if (positionHistory != null) { @@ -65,6 +72,12 @@ class PositionDetailFragment : BaseFragment() { positionHistory = positionHistory, pop = { activity?.onBackPressedDispatcher?.onBackPressed() + }, + onTradeAgain = { + openTradeAgain(positionHistory) + }, + onShare = { + PerpsPositionShareActivity.show(requireContext(), positionHistory) } ) } @@ -72,4 +85,24 @@ class PositionDetailFragment : BaseFragment() { } } } + + private fun showCloseDialog(position: PerpsPositionItem) { + val perpsPosition = position.toPosition() + PerpsCloseBottomSheetDialogFragment.newInstance(perpsPosition) + .setOnDone { + activity?.onBackPressedDispatcher?.onBackPressed() + } + .showNow(parentFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + } + + private fun openTradeAgain(positionHistory: PerpsPositionHistoryItem) { + val isLong = positionHistory.side.equals("long", ignoreCase = true) + PerpsActivity.showOpenPosition( + context = requireContext(), + marketId = positionHistory.productId, + marketSymbol = positionHistory.marketSymbol ?: positionHistory.tokenSymbol ?: "", + marketDisplaySymbol = positionHistory.displaySymbol ?: positionHistory.tokenSymbol ?: "", + isLong = isLong + ) + } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt index b3e5d6746b..c90d6f4b7b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt @@ -26,16 +26,13 @@ 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.fragment.app.FragmentActivity import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem -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.ui.wallet.alert.components.cardBackground @@ -47,8 +44,9 @@ import java.util.Locale fun PositionDetailPage( position: PerpsPositionItem, pop: () -> Unit, + onClose: (() -> Unit)? = null, + onShare: (() -> Unit)? = null, ) { - val context = LocalContext.current val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) fun formatDate(dateStr: String?): String { @@ -144,11 +142,12 @@ fun PositionDetailPage( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.Trade_Again), + text = stringResource(R.string.Close), color = MixinAppTheme.colors.textPrimary, fontWeight = FontWeight.W500, modifier = Modifier .weight(1f) + .clickable { onClose?.invoke() } .padding(vertical = 10.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center ) @@ -164,6 +163,7 @@ fun PositionDetailPage( fontWeight = FontWeight.W500, modifier = Modifier .weight(1f) + .clickable { onShare?.invoke() } .padding(vertical = 10.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center ) @@ -275,6 +275,8 @@ private fun PositionDetailItem( fun PositionDetailPage( positionHistory: PerpsPositionHistoryItem, pop: () -> Unit, + onTradeAgain: (() -> Unit)? = null, + onShare: (() -> Unit)? = null, ) { val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) @@ -376,6 +378,7 @@ fun PositionDetailPage( fontWeight = FontWeight.W500, modifier = Modifier .weight(1f) + .clickable { onTradeAgain?.invoke() } .padding(vertical = 10.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center ) @@ -391,6 +394,7 @@ fun PositionDetailPage( fontWeight = FontWeight.W500, modifier = Modifier .weight(1f) + .clickable { onShare?.invoke() } .padding(vertical = 10.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center ) diff --git a/app/src/main/res/drawable-xxhdpi/ic_perps_loss.png b/app/src/main/res/drawable-xxhdpi/ic_perps_loss.png new file mode 100644 index 0000000000000000000000000000000000000000..46f6900f7a6b744f85f5a63406b045f2b5ab19d8 GIT binary patch literal 81330 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 >USbh>A});K91kPA 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 zrX1EX2#6g30000000000000000000009Si(NB{r;03hi9YhRcE00342rkTCG9y3bo P00000NkvXXu0mjf<4fH} literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/bg_perps_share_card_container.xml b/app/src/main/res/drawable/bg_perps_share_card_container.xml new file mode 100644 index 0000000000..8c5e182860 --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_share_card_container.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_perps_share_card_loss.xml b/app/src/main/res/drawable/bg_perps_share_card_loss.xml new file mode 100644 index 0000000000..6d003c201a --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_share_card_loss.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_perps_share_card_profit.xml b/app/src/main/res/drawable/bg_perps_share_card_profit.xml new file mode 100644 index 0000000000..fb8e25a266 --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_share_card_profit.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_perps_share_footer.xml b/app/src/main/res/drawable/bg_perps_share_footer.xml new file mode 100644 index 0000000000..1fc265ee46 --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_share_footer.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_perps_share_tag.xml b/app/src/main/res/drawable/bg_perps_share_tag.xml new file mode 100644 index 0000000000..f1d1239b8c --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_share_tag.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_perps_share_loss.xml b/app/src/main/res/drawable/ic_perps_share_loss.xml new file mode 100644 index 0000000000..15d1c3f731 --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_share_loss.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_perps_share_profit.xml b/app/src/main/res/drawable/ic_perps_share_profit.xml new file mode 100644 index 0000000000..29e6a5021e --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_share_profit.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_perps_position_share.xml b/app/src/main/res/layout/activity_perps_position_share.xml new file mode 100644 index 0000000000..b3762d7ae3 --- /dev/null +++ b/app/src/main/res/layout/activity_perps_position_share.xml @@ -0,0 +1,341 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3617591a1b..1b7233ca3d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2285,6 +2285,7 @@ 方向 开仓价格 价格 + 最新价格 平仓价格 数量 持仓详情 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index afacdc7987..0338a4908e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2344,6 +2344,7 @@ Side Entry Price Mark Price + Latest Price Close Price Quantity Position Details From 830affd0034b97f2212406db3519a4cac8d0b6a2 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 3 Mar 2026 17:05:46 +0800 Subject: [PATCH 025/105] Update detail --- .../mixin/android/db/perps/PerpsMarketDao.kt | 2 +- .../ui/home/web3/trade/ClosedPositionItem.kt | 2 +- .../ui/home/web3/trade/OpenPositionItem.kt | 4 +- .../ui/home/web3/trade/OpenPositionPage.kt | 1 + .../ui/home/web3/trade/PositionDetailPage.kt | 130 ++++++++++++------ .../layout/activity_perps_position_share.xml | 6 +- 6 files changed, 96 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt index 1f35ca73b5..a6bf75601a 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt @@ -15,7 +15,7 @@ interface PerpsMarketDao : BaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(markets: List) - @Query("SELECT * FROM markets ORDER BY CAST(volume AS REAL) DESC") + @Query("SELECT * FROM markets") suspend fun getAllMarkets(): List @Query("SELECT * FROM markets WHERE market_id = :marketId") diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index 3e3b105f74..cedfaac945 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -126,7 +126,7 @@ fun ClosedPositionItem( horizontalAlignment = Alignment.End ) { Text( - text = String.format("$%.2f", pnl.abs()), + text = String.format("$%f", pnl.abs()), fontSize = 14.sp, color = pnlColor ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt index d37ad3eb7b..8d22440de4 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt @@ -110,7 +110,7 @@ fun OpenPositionItem( Column(horizontalAlignment = Alignment.End) { Text( - text = String.format("$%.2f", pnl.abs()), + text = String.format("$%f", pnl.abs()), fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary ) @@ -131,7 +131,7 @@ fun OpenPositionItem( } } Text( - text = String.format("%s%.2f", if (unrealizedPnl >= BigDecimal.ZERO) "+" else "", unrealizedPnl), + text = String.format("%s%f", if (unrealizedPnl >= BigDecimal.ZERO) "+" else "", unrealizedPnl), fontSize = 12.sp, color = pnlColor ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index a1608167ab..8586db2cc3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -233,6 +233,7 @@ fun OpenPositionPage( isLong = isLong ).setOnLeverageSelected { newLeverage -> leverage = newLeverage + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), newLeverage.toInt()) }.show(activity.supportFragmentManager, LeverageBottomSheetDialogFragment.TAG) }, text = "${leverage.toInt()}x", diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt index c90d6f4b7b..db4f029dd4 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt @@ -69,8 +69,19 @@ fun PositionDetailPage( val isProfit = pnl >= BigDecimal.ZERO val pnlColor = if (isProfit) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val sideText = if (position.side.lowercase() == "long") { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + val title = "Opened $sideText" + + val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO + val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val orderValue = quantity * markPrice + PageScaffold( - title = stringResource(R.string.Position_Details), + title = title, verticalScrollable = false, pop = pop ) { @@ -100,14 +111,26 @@ fun PositionDetailPage( ) Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = String.format("$%.2f", pnl.abs()), - fontSize = 24.sp, - fontWeight = FontWeight.W500, - color = pnlColor, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) + + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + val sideText = if (position.side.lowercase() == "long") { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + Text( + text = "$sideText ", + fontSize = 24.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + ) + Text( + text = position.tokenSymbol ?: "", + fontSize = 24.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + ) + } Spacer(modifier = Modifier.height(10.dp)) @@ -195,32 +218,33 @@ fun PositionDetailPage( PositionDetailItem( label = stringResource(R.string.Order_Value).uppercase(), - value = "${position.quantity.toBigDecimalOrNull()?.let { String.format("%.4f", it) } ?: position.quantity} ${position.tokenSymbol ?: ""}" + value = "${String.format("%.4f", quantity)} ${position.tokenSymbol ?: ""}", + subtitle = String.format("$%f", orderValue) ) - + Spacer(modifier = Modifier.height(20.dp)) - + PositionDetailItem( label = stringResource(R.string.Entry_Price).uppercase(), - value = String.format("$%.2f", position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + value = String.format("$%f", position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) ) - + Spacer(modifier = Modifier.height(20.dp)) - + one.mixin.android.ui.tip.wc.compose.ItemWalletContent( title = stringResource(R.string.Wallet).uppercase(), fontSize = 16.sp, padding = 0.dp ) - + Spacer(modifier = Modifier.height(20.dp)) - + PositionDetailItem( label = stringResource(R.string.Open_Time).uppercase(), value = formatDate(position.createdAt) ) } - + Spacer(modifier = Modifier.height(40.dp)) } } @@ -230,7 +254,8 @@ fun PositionDetailPage( private fun PositionDetailItem( label: String, value: String, - icon: String? = null + icon: String? = null, + subtitle: String? = null ) { Column( modifier = Modifier.fillMaxWidth() @@ -241,7 +266,7 @@ private fun PositionDetailItem( color = MixinAppTheme.colors.textAssist ) Spacer(modifier = Modifier.height(4.dp)) - + if (icon != null) { Row(verticalAlignment = Alignment.CenterVertically) { CoilImage( @@ -267,6 +292,15 @@ private fun PositionDetailItem( color = MixinAppTheme.colors.textPrimary ) } + + if (subtitle != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } } } @@ -279,7 +313,7 @@ fun PositionDetailPage( onShare: (() -> Unit)? = null, ) { val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - + fun formatDate(dateStr: String?): String { if (dateStr == null) return "" return try { @@ -300,8 +334,19 @@ fun PositionDetailPage( val isProfit = pnl >= BigDecimal.ZERO val pnlColor = if (isProfit) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val sideText = if (positionHistory.side.lowercase() == "long") { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + val title = "Closed $sideText" + + val quantity = positionHistory.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO + val closePrice = positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + val orderValue = quantity * closePrice + PageScaffold( - title = stringResource(R.string.Position_Details), + title = title, verticalScrollable = false, pop = pop ) { @@ -320,7 +365,7 @@ fun PositionDetailPage( ) ) { Spacer(modifier = Modifier.height(30.dp)) - + CoilImage( model = positionHistory.iconUrl, placeholder = R.drawable.ic_avatar_place_holder, @@ -329,19 +374,19 @@ fun PositionDetailPage( .clip(CircleShape) .align(Alignment.CenterHorizontally) ) - + Spacer(modifier = Modifier.height(20.dp)) - + Text( - text = String.format("$%.2f", pnl.abs()), + text = String.format("$%f", pnl.abs()), fontSize = 24.sp, fontWeight = FontWeight.W500, color = pnlColor, modifier = Modifier.align(Alignment.CenterHorizontally) ) - + Spacer(modifier = Modifier.height(10.dp)) - + Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) @@ -360,9 +405,9 @@ fun PositionDetailPage( fontSize = 14.sp ) } - + Spacer(modifier = Modifier.height(20.dp)) - + Row( modifier = Modifier .fillMaxWidth() @@ -399,12 +444,12 @@ fun PositionDetailPage( textAlign = androidx.compose.ui.text.style.TextAlign.Center ) } - + Spacer(modifier = Modifier.height(30.dp)) } - + Spacer(modifier = Modifier.height(10.dp)) - + Column( modifier = Modifier .fillMaxWidth() @@ -421,26 +466,27 @@ fun PositionDetailPage( value = positionHistory.displaySymbol ?: positionHistory.tokenSymbol ?: "Unknown", icon = positionHistory.iconUrl ) - + Spacer(modifier = Modifier.height(20.dp)) - + PositionDetailItem( label = stringResource(R.string.Order_Value).uppercase(), - value = "${positionHistory.quantity.toBigDecimalOrNull()?.let { String.format("%.4f", it) } ?: positionHistory.quantity} ${positionHistory.tokenSymbol ?: ""}" + value = "${String.format("%.4f", quantity)} ${positionHistory.tokenSymbol ?: ""}", + subtitle = String.format("$%f", orderValue) ) - + Spacer(modifier = Modifier.height(20.dp)) - + PositionDetailItem( label = stringResource(R.string.Entry_Price).uppercase(), - value = String.format("$%.2f", positionHistory.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + value = String.format("$%f", positionHistory.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) ) - + Spacer(modifier = Modifier.height(20.dp)) - + PositionDetailItem( label = stringResource(R.string.Close_Price).uppercase(), - value = String.format("$%.2f", positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + value = String.format("$%f", positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) ) Spacer(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/res/layout/activity_perps_position_share.xml b/app/src/main/res/layout/activity_perps_position_share.xml index b3762d7ae3..0ad32bba48 100644 --- a/app/src/main/res/layout/activity_perps_position_share.xml +++ b/app/src/main/res/layout/activity_perps_position_share.xml @@ -282,7 +282,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/Share" - android:textColor="@color/white" + android:textColor="?attr/text_primary" android:textSize="12sp" /> @@ -307,7 +307,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/Link" - android:textColor="@color/white" + android:textColor="?attr/text_primary" android:textSize="12sp" /> @@ -332,7 +332,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/Save" - android:textColor="@color/white" + android:textColor="?attr/text_primary" android:textSize="12sp" /> From 82c7ffcfc19b3f346aef64c148c5a876b3be6550 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 10:38:25 +0800 Subject: [PATCH 026/105] Update empty --- .../mixin/android/ui/home/web3/trade/AllPositionsFragment.kt | 4 ++-- app/src/main/res/layout/fragment_all_closed_positions.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt index 56330f7561..79fa9697d2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt @@ -80,7 +80,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions binding.progressBar.isVisible = false openPositionAdapter.submitList(pagedList) val isEmpty = pagedList.isEmpty() - binding.emptyView.infoTv.text = getString(R.string.No_Positions) + binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Positions) binding.emptyView.root.isVisible = isEmpty } @@ -88,7 +88,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions binding.progressBar.isVisible = false closedPositionAdapter.submitList(pagedList) val isEmpty = pagedList.isEmpty() - binding.emptyView.infoTv.text = getString(R.string.No_Closed_Positions) + binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Closed_Positions) binding.emptyView.root.isVisible = isEmpty } diff --git a/app/src/main/res/layout/fragment_all_closed_positions.xml b/app/src/main/res/layout/fragment_all_closed_positions.xml index 55fa726057..bb96566b06 100644 --- a/app/src/main/res/layout/fragment_all_closed_positions.xml +++ b/app/src/main/res/layout/fragment_all_closed_positions.xml @@ -76,7 +76,7 @@ Date: Wed, 4 Mar 2026 10:55:13 +0800 Subject: [PATCH 027/105] Update quote color --- .../web3/trade/AllPerpsMarketsFragment.kt | 7 ++ .../home/web3/trade/AllPositionsFragment.kt | 9 +- .../home/web3/trade/ClosedPositionAdapter.kt | 12 +- .../LeverageBottomSheetDialogFragment.kt | 107 ++++++++++++++---- .../android/ui/home/web3/trade/MarketItem.kt | 15 ++- .../ui/home/web3/trade/MarketListAdapter.kt | 14 ++- .../MarketListBottomSheetDialogFragment.kt | 9 +- .../ui/home/web3/trade/OpenPositionAdapter.kt | 25 ++-- .../ui/home/web3/trade/PerpetualContent.kt | 13 ++- 9 files changed, 164 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt index a21b119398..3d73c622a3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -28,9 +29,11 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.isNightMode import one.mixin.android.extension.toast import one.mixin.android.ui.common.BaseFragment @@ -112,6 +115,9 @@ private fun AllMarketsPage( pop: () -> Unit, onMarketClick: (PerpsMarket) -> Unit, ) { + val context = LocalContext.current + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) val viewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel() var markets by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } @@ -183,6 +189,7 @@ private fun AllMarketsPage( Column(modifier = Modifier.fillMaxWidth()) { MarketItem( market = market, + quoteColorReversed = quoteColorReversed, onClick = { onMarketClick(market) } ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt index 79fa9697d2..e0ff21ef9c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt @@ -12,10 +12,12 @@ import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.databinding.FragmentAllClosedPositionsBinding +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseFragment import one.mixin.android.util.viewBinding @@ -44,9 +46,12 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions private val binding by viewBinding(FragmentAllClosedPositionsBinding::bind) private val viewModel by viewModels() private val totalValueAdapter by lazy { TotalPositionValueAdapter() } + private val isQuoteColorReversed by lazy { + requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + } private val openPositionAdapter by lazy { - OpenPositionAdapter { position -> + OpenPositionAdapter(isQuoteColorReversed) { position -> activity?.supportFragmentManager?.let { fm -> fm.beginTransaction() .add(android.R.id.content, PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) @@ -57,7 +62,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions } private val closedPositionAdapter by lazy { - ClosedPositionAdapter { position -> + ClosedPositionAdapter(isQuoteColorReversed) { position -> activity?.supportFragmentManager?.let { fm -> fm.beginTransaction() .add(android.R.id.content, PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt index 3beca6180c..48fbf9cc8d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt @@ -19,6 +19,7 @@ import one.mixin.android.ui.common.recyclerview.SafePagedListAdapter import java.math.BigDecimal class ClosedPositionAdapter( + private val isQuoteColorReversed: Boolean = false, private val onItemClick: ((PerpsPositionHistoryItem) -> Unit)? = null ) : SafePagedListAdapter(DiffCallback()) { @@ -29,6 +30,7 @@ class ClosedPositionAdapter( parent, false ), + isQuoteColorReversed, onItemClick ) } @@ -40,6 +42,7 @@ class ClosedPositionAdapter( class ViewHolder( private val binding: ItemClosedPositionListBinding, + private val isQuoteColorReversed: Boolean, private val onItemClick: ((PerpsPositionHistoryItem) -> Unit)? ) : RecyclerView.ViewHolder(binding.root) { @@ -68,11 +71,6 @@ class ClosedPositionAdapter( } else { context.getString(R.string.Short) } - val sideColor = if (isLong) { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) - } val displaySymbol = position.tokenSymbol ?: context.getString(R.string.Unknown) titleTv.text = context.getString(R.string.Perpetual_Side_Symbol_Title, sideText, displaySymbol) @@ -86,11 +84,11 @@ class ClosedPositionAdapter( rightTopValueTv.setTextColor( when { pnl > BigDecimal.ZERO -> { - context.getColor(R.color.wallet_green) + context.getColor(if (isQuoteColorReversed) R.color.wallet_red else R.color.wallet_green) } pnl < BigDecimal.ZERO -> { - context.getColor(R.color.wallet_red) + context.getColor(if (isQuoteColorReversed) R.color.wallet_green else R.color.wallet_red) } else -> { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt index 806437a82c..98c042c3ec 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt @@ -185,7 +185,9 @@ private fun LeverageContent( colors = SliderDefaults.colors( thumbColor = MixinAppTheme.colors.accent, activeTrackColor = MixinAppTheme.colors.accent, - inactiveTrackColor = MixinAppTheme.colors.backgroundWindow + inactiveTrackColor = MixinAppTheme.colors.backgroundWindow, + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent ), modifier = Modifier.fillMaxWidth() ) @@ -201,7 +203,7 @@ private fun LeverageContent( for (i in 0 until steps) { val value = if (i == steps - 1) maxLeverage else (i * stepValue) Text( - text = if (i == steps - 1) "Max" else "${value}x", + text = "${value}x", fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) @@ -211,30 +213,12 @@ private fun LeverageContent( Spacer(modifier = Modifier.height(12.dp)) - val profitInfo = calculateProfitLossInfo( + ProfitLossInfo( amount = amount, leverage = tempLeverage, isLong = isLong ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp) - ) { - Text( - text = profitInfo.first, - fontSize = 13.sp, - color = MixinAppTheme.colors.walletGreen - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = profitInfo.second, - fontSize = 13.sp, - color = MixinAppTheme.colors.walletRed - ) - } - Spacer(modifier = Modifier.height(28.dp)) Row( @@ -280,6 +264,87 @@ private fun LeverageContent( } } +@Composable +private fun ProfitLossInfo( + amount: String, + leverage: Float, + isLong: Boolean +) { + val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO + + if (amountValue == BigDecimal.ZERO) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + Text( + text = stringResource(R.string.Price_Up_Profit, "1", "0.0", "0.00"), + fontSize = 13.sp, + color = MixinAppTheme.colors.walletGreen + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.Price_Down_Loss, String.format("%.2f", 100.0 / leverage), "0.00"), + fontSize = 13.sp, + color = MixinAppTheme.colors.walletRed + ) + } + return + } + + val priceUpPercent = 1.0 + val profitPercent = priceUpPercent * leverage + val profitAmount = amountValue * BigDecimal(profitPercent / 100) + + val liquidationPercent = 100.0 / leverage + val lossAmount = amountValue + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + Text( + text = if (isLong) { + stringResource( + R.string.Price_Up_Profit, + String.format("%.0f", abs(priceUpPercent)), + String.format("%.0f", profitPercent), + String.format("%.2f", profitAmount) + ) + } else { + stringResource( + R.string.Price_Down_Profit, + String.format("%.0f", abs(priceUpPercent)), + String.format("%.0f", profitPercent), + String.format("%.2f", profitAmount) + ) + }, + fontSize = 13.sp, + color = MixinAppTheme.colors.walletGreen + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (isLong) { + stringResource( + R.string.Price_Down_Loss, + String.format("%.2f", liquidationPercent), + String.format("%.2f", lossAmount) + ) + } else { + stringResource( + R.string.Price_Up_Loss, + String.format("%.2f", liquidationPercent), + String.format("%.2f", lossAmount) + ) + }, + fontSize = 13.sp, + color = MixinAppTheme.colors.walletRed + ) + } +} + private fun calculateProfitLossInfo( amount: String, leverage: Float, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt index 2d3b135b05..693adad8a2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt @@ -34,6 +34,7 @@ import java.math.BigDecimal @Composable fun MarketItem( market: PerpsMarket, + quoteColorReversed: Boolean = false, onClick: () -> Unit = {} ) { val change = try { @@ -43,7 +44,19 @@ fun MarketItem( } val isPositive = change >= BigDecimal.ZERO - val changeColor = if (isPositive) Color(0xFF4CAF50) else Color(0xFFF44336) + val changeColor = if (isPositive) { + if (quoteColorReversed) { + MixinAppTheme.colors.walletRed + } else { + MixinAppTheme.colors.walletGreen + } + } else { + if (quoteColorReversed) { + MixinAppTheme.colors.walletGreen + } else { + MixinAppTheme.colors.walletRed + } + } val changeText = "${if (isPositive) "+" else ""}${market.change}%" val formattedPrice = try { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt index 72cf8c9fec..14fe1fbd89 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt @@ -12,6 +12,7 @@ import one.mixin.android.extension.numberFormatCompact import java.math.BigDecimal class MarketListAdapter( + private val isQuoteColorReversed: Boolean, private val onMarketClick: (PerpsMarket) -> Unit ) : RecyclerView.Adapter() { @@ -73,11 +74,14 @@ class MarketListAdapter( } val isPositive = change >= BigDecimal.ZERO - val changeColor = if (isPositive) { - ContextCompat.getColor(root.context, R.color.wallet_green) - } else { - ContextCompat.getColor(root.context, R.color.wallet_red) - } + val changeColor = ContextCompat.getColor( + root.context, + if (isPositive) { + if (isQuoteColorReversed) R.color.wallet_red else R.color.wallet_green + } else { + if (isQuoteColorReversed) R.color.wallet_green else R.color.wallet_red + } + ) val changeText = "${if (isPositive) "+" else ""}${market.change}%" changeTv.text = changeText diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt index 1150e5cc37..a34f7c5e6f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt @@ -12,10 +12,12 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import one.mixin.android.Constants import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.databinding.FragmentMarketListBottomSheetBinding import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.extension.appCompatActionBarHeight +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.withArgs import one.mixin.android.ui.common.MixinBottomSheetDialogFragment @@ -36,7 +38,12 @@ class MarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } private val binding by viewBinding(FragmentMarketListBottomSheetBinding::inflate) - private val adapter by lazy { MarketListAdapter { market -> onMarketClick(market) } } + private val isQuoteColorReversed by lazy { + requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + } + private val adapter by lazy { + MarketListAdapter(isQuoteColorReversed) { market -> onMarketClick(market) } + } @Inject lateinit var perpsMarketDao: PerpsMarketDao diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt index 2bc917d29a..2ce69aa464 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt @@ -19,6 +19,7 @@ import one.mixin.android.ui.common.recyclerview.SafePagedListAdapter import java.math.BigDecimal class OpenPositionAdapter( + private val isQuoteColorReversed: Boolean = false, private val onItemClick: ((PerpsPositionItem) -> Unit)? = null ) : SafePagedListAdapter(DiffCallback()) { @@ -29,6 +30,7 @@ class OpenPositionAdapter( parent, false ), + isQuoteColorReversed, onItemClick ) } @@ -40,6 +42,7 @@ class OpenPositionAdapter( class ViewHolder( private val binding: ItemClosedPositionListBinding, + private val isQuoteColorReversed: Boolean, private val onItemClick: ((PerpsPositionItem) -> Unit)? ) : RecyclerView.ViewHolder(binding.root) { @@ -68,18 +71,24 @@ class OpenPositionAdapter( } else { context.getString(R.string.Short) } - val sideColor = if (isLong) { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) - } + val sideColor = context.getColor( + if (isLong) { + if (isQuoteColorReversed) R.color.wallet_red else R.color.wallet_green + } else { + if (isQuoteColorReversed) R.color.wallet_green else R.color.wallet_red + } + ) val displaySymbol = position.tokenSymbol ?: context.getString(R.string.Unknown) titleTv.text = context.getString(R.string.Perpetual_Side_Symbol_Title, sideText, displaySymbol) leverageTv.isVisible = true leverageTv.text = context.getString(R.string.Perpetual_Leverage_Format, position.leverage) leverageTv.setTextColor(sideColor) leverageTv.setBackgroundResource( - if (isLong) R.drawable.bg_perps_leverage_long else R.drawable.bg_perps_leverage_short + if (isLong) { + if (isQuoteColorReversed) R.drawable.bg_perps_leverage_short else R.drawable.bg_perps_leverage_long + } else { + if (isQuoteColorReversed) R.drawable.bg_perps_leverage_long else R.drawable.bg_perps_leverage_short + } ) val quantity = position.quantity.toBigDecimalOrNull() @@ -97,11 +106,11 @@ class OpenPositionAdapter( rightBottomValueTv.setTextColor( when { pnl > BigDecimal.ZERO -> { - context.getColor(R.color.wallet_green) + context.getColor(if (isQuoteColorReversed) R.color.wallet_red else R.color.wallet_green) } pnl < BigDecimal.ZERO -> { - context.getColor(R.color.wallet_red) + context.getColor(if (isQuoteColorReversed) R.color.wallet_green else R.color.wallet_red) } else -> { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index d716393dbd..5927da0ac6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -32,6 +32,7 @@ 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -40,10 +41,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import one.mixin.android.R +import one.mixin.android.Constants import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground @@ -58,7 +61,12 @@ fun PerpetualContent( onMarketItemClick: (PerpsMarket) -> Unit, onClosedPositionClick: (PerpsPositionHistoryItem) -> Unit, ) { + val context = LocalContext.current val walletId = Session.getAccountId()!! + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed val viewModel = hiltViewModel() var markets by remember { mutableStateOf>(emptyList()) } @@ -147,13 +155,13 @@ fun PerpetualContent( Text( text = String.format("$%.2f", totalPnl), fontSize = 14.sp, - color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, + color = if (totalPnl >= 0) risingColor else fallingColor, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = String.format("(%s%.1f%%)", if (totalPnl >= 0) "+" else "", totalPnl), fontSize = 14.sp, - color = if (totalPnl >= 0) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed, + color = if (totalPnl >= 0) risingColor else fallingColor, ) } } @@ -300,6 +308,7 @@ fun PerpetualContent( marketsPreview.forEach { market -> MarketItem( market = market, + quoteColorReversed = quoteColorReversed, onClick = { onMarketItemClick(market) } From 829a616df538053beff49c040a959f2b72f6353d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 11:10:11 +0800 Subject: [PATCH 028/105] Update price format --- .../home/web3/trade/ClosedPositionAdapter.kt | 27 +++----- .../ui/home/web3/trade/ClosedPositionItem.kt | 9 ++- .../LeverageBottomSheetDialogFragment.kt | 61 +++++------------- .../ui/home/web3/trade/MarketDetailPage.kt | 10 +-- .../android/ui/home/web3/trade/MarketItem.kt | 21 +++---- .../ui/home/web3/trade/MarketListAdapter.kt | 17 +++-- .../ui/home/web3/trade/OpenPositionAdapter.kt | 35 +++++------ .../ui/home/web3/trade/OpenPositionItem.kt | 11 ++-- .../ui/home/web3/trade/OpenPositionPage.kt | 63 ++++++++++++++----- .../ui/home/web3/trade/PerpetualContent.kt | 12 +++- .../ui/home/web3/trade/PerpsActivity.kt | 3 +- .../PerpsCloseBottomSheetDialogFragment.kt | 2 +- .../web3/trade/PerpsPositionShareActivity.kt | 19 ++++-- .../home/web3/trade/PositionDetailFragment.kt | 8 +++ .../ui/home/web3/trade/PositionDetailPage.kt | 40 +++++++++--- .../web3/trade/TotalPositionValueAdapter.kt | 52 +++++++++++---- .../ui/wallet/MarketDetailsFragment.kt | 12 ++-- .../res/layout/item_closed_position_list.xml | 3 - .../res/layout/item_total_position_value.xml | 8 +-- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 21 files changed, 238 insertions(+), 177 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt index 48fbf9cc8d..d3df79bce2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt @@ -1,9 +1,5 @@ package one.mixin.android.ui.home.web3.trade -import android.content.Context -import android.text.SpannableString -import android.text.Spanned -import android.text.style.ForegroundColorSpan import android.view.View import android.view.LayoutInflater import android.view.ViewGroup @@ -15,7 +11,9 @@ import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.databinding.ItemClosedPositionListBinding import one.mixin.android.extension.loadImage +import one.mixin.android.extension.priceFormat import one.mixin.android.ui.common.recyclerview.SafePagedListAdapter +import one.mixin.android.vo.Fiats import java.math.BigDecimal class ClosedPositionAdapter( @@ -80,7 +78,7 @@ class ClosedPositionAdapter( quantityTv.text = "$quantityStr ${position.tokenSymbol ?: ""}" val pnl = position.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO - rightTopValueTv.text = formatSignedUsd(context, pnl) + rightTopValueTv.text = formatSignedUsd(pnl) rightTopValueTv.setTextColor( when { pnl > BigDecimal.ZERO -> { @@ -100,19 +98,14 @@ class ClosedPositionAdapter( } } - private fun formatSignedUsd(context: Context, amount: BigDecimal): String { + private fun formatSignedUsd(amount: BigDecimal): String { + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + val fiatAmount = amount.abs().multiply(fiatRate).priceFormat() return when { - amount > BigDecimal.ZERO -> context.getString( - R.string.Perpetual_Usd_Amount_Signed, - "+", - amount.abs().toDouble() - ) - amount < BigDecimal.ZERO -> context.getString( - R.string.Perpetual_Usd_Amount_Signed, - "-", - amount.abs().toDouble() - ) - else -> context.getString(R.string.Perpetual_Usd_Amount, 0.0) + amount > BigDecimal.ZERO -> "+$fiatSymbol$fiatAmount" + amount < BigDecimal.ZERO -> "-$fiatSymbol$fiatAmount" + else -> "$fiatSymbol${BigDecimal.ZERO.multiply(fiatRate).priceFormat()}" } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index cedfaac945..caeae7d9f1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.Constants @@ -29,7 +28,9 @@ import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.priceFormat import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.Fiats import java.math.BigDecimal @Composable @@ -40,6 +41,8 @@ fun ClosedPositionItem( val context = LocalContext.current val quoteColorPref = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() val pnl = try { BigDecimal(position.realizedPnl) @@ -65,7 +68,7 @@ fun ClosedPositionItem( val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" val quantity = try { val qty = BigDecimal(position.quantity) - String.format("%.4f", qty) + String.format("%f", qty) } catch (e: Exception) { position.quantity } @@ -126,7 +129,7 @@ fun ClosedPositionItem( horizontalAlignment = Alignment.End ) { Text( - text = String.format("$%f", pnl.abs()), + text = "${if (isProfit) "+" else "-"}$fiatSymbol${pnl.abs().multiply(fiatRate).priceFormat()}", fontSize = 14.sp, color = pnlColor ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt index 98c042c3ec..eb75f30dc3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt @@ -167,9 +167,10 @@ private fun LeverageContent( .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) .padding(16.dp) ) { + Spacer(modifier = Modifier.height(24.dp)) Text( text = "${tempLeverage.toInt()}x", - fontSize = 32.sp, + fontSize = 48.sp, fontWeight = FontWeight.Bold, color = MixinAppTheme.colors.textPrimary, modifier = Modifier.align(Alignment.CenterHorizontally) @@ -219,7 +220,7 @@ private fun LeverageContent( isLong = isLong ) - Spacer(modifier = Modifier.height(28.dp)) + Spacer(modifier = Modifier.height(48.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -279,13 +280,13 @@ private fun ProfitLossInfo( .padding(horizontal = 4.dp) ) { Text( - text = stringResource(R.string.Price_Up_Profit, "1", "0.0", "0.00"), + text = stringResource(R.string.Price_Up_Profit, "1", "0.0", "$0.00"), fontSize = 13.sp, color = MixinAppTheme.colors.walletGreen ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = stringResource(R.string.Price_Down_Loss, String.format("%.2f", 100.0 / leverage), "0.00"), + text = stringResource(R.string.Price_Down_Loss, String.format("%.2f", 100.0 / leverage), "$0.00", ""), fontSize = 13.sp, color = MixinAppTheme.colors.walletRed ) @@ -311,18 +312,18 @@ private fun ProfitLossInfo( R.string.Price_Up_Profit, String.format("%.0f", abs(priceUpPercent)), String.format("%.0f", profitPercent), - String.format("%.2f", profitAmount) + String.format("$%.2f", profitAmount) ) } else { stringResource( R.string.Price_Down_Profit, String.format("%.0f", abs(priceUpPercent)), String.format("%.0f", profitPercent), - String.format("%.2f", profitAmount) + String.format("$%.2f", profitAmount) ) }, fontSize = 13.sp, - color = MixinAppTheme.colors.walletGreen + color = MixinAppTheme.colors.textAssist ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -330,53 +331,19 @@ private fun ProfitLossInfo( stringResource( R.string.Price_Down_Loss, String.format("%.2f", liquidationPercent), - String.format("%.2f", lossAmount) + String.format("$%.2f", lossAmount), + "" ) } else { stringResource( R.string.Price_Up_Loss, String.format("%.2f", liquidationPercent), - String.format("%.2f", lossAmount) + String.format("$%.2f", lossAmount), + "" ) }, fontSize = 13.sp, - color = MixinAppTheme.colors.walletRed - ) - } -} - -private fun calculateProfitLossInfo( - amount: String, - leverage: Float, - isLong: Boolean -): Pair { - val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO - - if (amountValue == BigDecimal.ZERO) { - return Pair( - "价格上涨 1% → 盈利 0%(+$0)", - "价格下跌 ${String.format("%.2f", 100.0 / leverage)}% → 亏损 -$0" + color = MixinAppTheme.colors.textAssist ) } - - val priceUpPercent = 1.0 - val profitPercent = priceUpPercent * leverage - val profitAmount = amountValue * BigDecimal(profitPercent / 100) - - val liquidationPercent = 100.0 / leverage - val lossAmount = amountValue - - val profitText = if (isLong) { - "价格上涨 ${String.format("%.0f", abs(priceUpPercent))}% → 盈利 ${String.format("%.0f", profitPercent)}%(+$${String.format("%.2f", profitAmount)})" - } else { - "价格下跌 ${String.format("%.0f", abs(priceUpPercent))}% → 盈利 ${String.format("%.0f", profitPercent)}%(+$${String.format("%.2f", profitAmount)})" - } - - val lossText = if (isLong) { - "价格下跌 ${String.format("%.2f", liquidationPercent)}% → 亏损 -$${String.format("%.2f", lossAmount)}" - } else { - "价格上涨 ${String.format("%.2f", liquidationPercent)}% → 亏损 -$${String.format("%.2f", lossAmount)}" - } - - return Pair(profitText, lossText) -} +} \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt index 0ae8b691bd..55571e6dea 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt @@ -47,7 +47,6 @@ import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R 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 @@ -58,9 +57,10 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground import java.math.BigDecimal @Composable -fun MarketDetailPage( +fun PerpsMarketDetailPage( marketId: String, marketSymbol: String, + displaySymbol: String, onBack: () -> Unit, ) { val context = LocalContext.current @@ -95,7 +95,7 @@ fun MarketDetailPage( } PageScaffold( - title = marketSymbol, + title = displaySymbol, verticalScrollable = false, pop = onBack ) { @@ -118,6 +118,7 @@ fun MarketDetailPage( MarketDetailCard( market = market!!, marketSymbol = marketSymbol, + displaySymbol = displaySymbol, selectedTimeFrame = selectedTimeFrame, timeFrames = timeFrames, onTimeFrameChange = { index -> @@ -373,6 +374,7 @@ private fun formatVolume(volume: String): String { private fun MarketDetailCard( market: PerpsMarket, marketSymbol: String, + displaySymbol: String, selectedTimeFrame: Int, timeFrames: List, onTimeFrameChange: (Int) -> Unit, @@ -424,7 +426,7 @@ private fun MarketDetailCard( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = marketSymbol, + text = displaySymbol, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MixinAppTheme.colors.textPrimary diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt index 693adad8a2..60c664cc5c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt @@ -18,9 +18,7 @@ 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.layout.ContentScale import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R @@ -28,7 +26,9 @@ import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.numberFormatCompact +import one.mixin.android.extension.priceFormat import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.Fiats import java.math.BigDecimal @Composable @@ -58,22 +58,17 @@ fun MarketItem( } } val changeText = "${if (isPositive) "+" else ""}${market.change}%" + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() val formattedPrice = try { - val price = BigDecimal(market.markPrice) - if (price >= BigDecimal("1000")) { - String.format("%.2f", price) - } else if (price >= BigDecimal("1")) { - String.format("%.4f", price) - } else { - String.format("%.6f", price) - } + BigDecimal(market.markPrice).multiply(fiatRate).priceFormat() } catch (e: Exception) { market.markPrice } val formattedVolume = try { - BigDecimal(market.volume).numberFormatCompact() + BigDecimal(market.volume).multiply(fiatRate).numberFormatCompact() } catch (e: Exception) { market.volume } @@ -125,7 +120,7 @@ fun MarketItem( } Spacer(modifier = Modifier.height(2.dp)) Text( - text = stringResource(R.string.Vol, formattedVolume), + text = stringResource(R.string.Vol, "$fiatSymbol$formattedVolume"), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist, ) @@ -136,7 +131,7 @@ fun MarketItem( horizontalAlignment = Alignment.End ) { Text( - text = "$$formattedPrice", + text = "$fiatSymbol$formattedPrice", fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt index 14fe1fbd89..f4e63cae5a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt @@ -9,6 +9,8 @@ import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.databinding.ItemMarketListBinding import one.mixin.android.extension.loadImage import one.mixin.android.extension.numberFormatCompact +import one.mixin.android.extension.priceFormat +import one.mixin.android.vo.Fiats import java.math.BigDecimal class MarketListAdapter( @@ -45,27 +47,24 @@ class MarketListAdapter( fun bind(market: PerpsMarket) { binding.apply { + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() iconIv.loadImage(market.iconUrl, R.drawable.ic_avatar_place_holder) symbolTv.text = market.displaySymbol val formattedVolume = try { - BigDecimal(market.volume).numberFormatCompact() + BigDecimal(market.volume).multiply(fiatRate).numberFormatCompact() } catch (e: Exception) { market.volume } - volumeTv.text = root.context.getString(R.string.Vol, formattedVolume) + volumeTv.text = root.context.getString(R.string.Vol, "$fiatSymbol$formattedVolume") val formattedPrice = try { - val price = BigDecimal(market.markPrice) - when { - price >= BigDecimal("1000") -> String.format("$%.2f", price) - price >= BigDecimal("1") -> String.format("$%.4f", price) - else -> String.format("$%.6f", price) - } + BigDecimal(market.markPrice).multiply(fiatRate).priceFormat() } catch (e: Exception) { market.markPrice } - priceTv.text = formattedPrice + priceTv.text = "$fiatSymbol$formattedPrice" val change = try { BigDecimal(market.change) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt index 2ce69aa464..6f7836fbcc 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt @@ -1,9 +1,5 @@ package one.mixin.android.ui.home.web3.trade -import android.content.Context -import android.text.SpannableString -import android.text.Spanned -import android.text.style.ForegroundColorSpan import android.view.View import android.view.LayoutInflater import android.view.ViewGroup @@ -15,7 +11,9 @@ import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.databinding.ItemClosedPositionListBinding import one.mixin.android.extension.loadImage +import one.mixin.android.extension.priceFormat import one.mixin.android.ui.common.recyclerview.SafePagedListAdapter +import one.mixin.android.vo.Fiats import java.math.BigDecimal class OpenPositionAdapter( @@ -97,12 +95,12 @@ class OpenPositionAdapter( val markPrice = (position.markPrice ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO val positionValue = (quantity ?: BigDecimal.ZERO).abs().multiply(markPrice) - rightTopValueTv.text = formatUsd(context, positionValue) + rightTopValueTv.text = formatUsd(positionValue) rightTopValueTv.setTextColor(resolveAttrColor(root, R.attr.text_primary)) rightBottomValueTv.isVisible = true val pnl = (position.unrealizedPnl ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO - rightBottomValueTv.text = formatSignedUsd(context, pnl) + rightBottomValueTv.text = formatSignedUsd(pnl) rightBottomValueTv.setTextColor( when { pnl > BigDecimal.ZERO -> { @@ -121,23 +119,20 @@ class OpenPositionAdapter( } } - private fun formatUsd(context: Context, amount: BigDecimal): String { - return context.getString(R.string.Perpetual_Usd_Amount, amount.toDouble()) + private fun formatUsd(amount: BigDecimal): String { + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + return "$fiatSymbol${amount.multiply(fiatRate).priceFormat()}" } - private fun formatSignedUsd(context: Context, amount: BigDecimal): String { + private fun formatSignedUsd(amount: BigDecimal): String { + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + val fiatAmount = amount.abs().multiply(fiatRate).priceFormat() return when { - amount > BigDecimal.ZERO -> context.getString( - R.string.Perpetual_Usd_Amount_Signed, - "+", - amount.abs().toDouble() - ) - amount < BigDecimal.ZERO -> context.getString( - R.string.Perpetual_Usd_Amount_Signed, - "-", - amount.abs().toDouble() - ) - else -> context.getString(R.string.Perpetual_Usd_Amount, 0.0) + amount > BigDecimal.ZERO -> "+$fiatSymbol$fiatAmount" + amount < BigDecimal.ZERO -> "-$fiatSymbol$fiatAmount" + else -> "$fiatSymbol${BigDecimal.ZERO.multiply(fiatRate).priceFormat()}" } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt index 8d22440de4..bfc0209d91 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.Constants @@ -29,7 +28,9 @@ import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.priceFormat import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.Fiats import java.math.BigDecimal @Composable @@ -41,9 +42,11 @@ fun OpenPositionItem( val quoteColorPref = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) val pnl = position.unrealizedPnl?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: stringResource(R.string.Unknown) - val quantity = position.quantity.toBigDecimalOrNull()?.let { String.format("%.4f", it) } ?: position.quantity + val quantity = position.quantity.toBigDecimalOrNull()?.let { String.format("%f", it) } ?: position.quantity Row( modifier = Modifier @@ -110,7 +113,7 @@ fun OpenPositionItem( Column(horizontalAlignment = Alignment.End) { Text( - text = String.format("$%f", pnl.abs()), + text = "${fiatSymbol}${pnl.abs().multiply(fiatRate).priceFormat()}", fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary ) @@ -131,7 +134,7 @@ fun OpenPositionItem( } } Text( - text = String.format("%s%f", if (unrealizedPnl >= BigDecimal.ZERO) "+" else "", unrealizedPnl), + text = "${if (unrealizedPnl >= BigDecimal.ZERO) "+" else "-"}$fiatSymbol${unrealizedPnl.abs().multiply(fiatRate).priceFormat()}", fontSize = 12.sp, color = pnlColor ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt index 8586db2cc3..af68ff6a68 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt @@ -55,15 +55,18 @@ import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences -import one.mixin.android.extension.putInt import one.mixin.android.extension.numberFormat8 +import one.mixin.android.extension.priceFormat +import one.mixin.android.extension.putInt import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.Fiats import one.mixin.android.vo.safe.TokenItem import java.math.BigDecimal import kotlin.math.abs @@ -108,6 +111,13 @@ fun OpenPositionPage( val maxLeverage = market?.leverage ?: 100 val leverageOptions = generateLeverageOptions(maxLeverage) + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val directionColor = if (isLong) risingColor else fallingColor + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() MixinAppTheme { PageScaffold( @@ -144,7 +154,10 @@ fun OpenPositionPage( color = MixinAppTheme.colors.textPrimary ) Text( - text = "${stringResource(R.string.Current_price, "${m.markPrice} USD")} ", + text = stringResource( + R.string.Current_price, + formatFiatPrice(m.markPrice, fiatRate, fiatSymbol) + ), fontSize = 13.sp, color = MixinAppTheme.colors.textAssist ) @@ -258,8 +271,8 @@ fun OpenPositionPage( } val displayText = when (lev) { - -1 -> "Custom" - maxLeverage -> "Max" + -1 -> stringResource(R.string.slippage_custom) + maxLeverage -> stringResource(R.string.Max) else -> "${lev}x" } @@ -293,7 +306,7 @@ fun OpenPositionPage( contentAlignment = Alignment.Center ) { Text( - modifier = Modifier.padding(horizontal = 10.dp).widthIn(min = 16.dp), + modifier = Modifier.padding(horizontal = 10.dp).widthIn(min = 20.dp), text = displayText, fontSize = 12.sp, color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textPrimary @@ -307,13 +320,15 @@ fun OpenPositionPage( amount = usdtAmount, leverage = leverage, isLong = isLong, - priceChangePercent = 1.0 + priceChangePercent = 1.0, + fiatRate = fiatRate, + fiatSymbol = fiatSymbol, ) Text( text = profitInfo, fontSize = 13.sp, - color = MixinAppTheme.colors.textAssist, + color = MixinAppTheme.colors.textMinor, modifier = Modifier.padding(horizontal = 4.dp) ) @@ -352,7 +367,9 @@ fun OpenPositionPage( text = calculateLiquidationPrice( market?.markPrice ?: "0", leverage, - isLong + isLong, + fiatRate, + fiatSymbol, ), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist @@ -473,32 +490,36 @@ private fun calculateProfitInfo( leverage: Float, isLong: Boolean, priceChangePercent: Double, + fiatRate: BigDecimal, + fiatSymbol: String, ): String { val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO if (amountValue == BigDecimal.ZERO) { return if (isLong) { - stringResource(R.string.Price_Up_Profit, "1", "0.0", "0.00") + stringResource(R.string.Price_Up_Profit, "1", "0.0", "${fiatSymbol}0.00") } else { - stringResource(R.string.Price_Down_Profit, "1", "0.0", "0.00") + stringResource(R.string.Price_Down_Profit, "1", "0.0", "${fiatSymbol}0.00") } } val profitPercent = priceChangePercent * leverage - val profitAmount = amountValue * BigDecimal(profitPercent / 100) + val profitAmount = amountValue + .multiply(BigDecimal(profitPercent / 100)) + .multiply(fiatRate) return if (isLong) { stringResource( R.string.Price_Up_Profit, String.format("%.0f", abs(priceChangePercent)), String.format("%.1f", profitPercent), - String.format("%.2f", profitAmount) + "${fiatSymbol}${profitAmount.priceFormat()}" ) } else { stringResource( R.string.Price_Down_Profit, String.format("%.0f", abs(priceChangePercent)), String.format("%.1f", profitPercent), - String.format("%.2f", profitAmount) + "${fiatSymbol}${profitAmount.priceFormat()}" ) } } @@ -522,12 +543,14 @@ private fun calculateLiquidationPrice( currentPrice: String, leverage: Float, isLong: Boolean, + fiatRate: BigDecimal, + fiatSymbol: String, ): String { val price = currentPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO if (price == BigDecimal.ZERO) { - return "$0" + return "${fiatSymbol}0" } val liquidationPercent = BigDecimal(100.0 / leverage) @@ -537,7 +560,15 @@ private fun calculateLiquidationPrice( } else { price * (BigDecimal.ONE + liquidationRatio) } + val fiatLiquidationPrice = liquidationPrice.multiply(fiatRate) + return "${fiatSymbol}${fiatLiquidationPrice.priceFormat()}" +} - val result = "$${String.format("%.2f", liquidationPrice)}" - return result +private fun formatFiatPrice( + rawPrice: String, + fiatRate: BigDecimal, + fiatSymbol: String, +): String { + val price = rawPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + return "${fiatSymbol}${price.multiply(fiatRate).priceFormat()}" } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt index 5927da0ac6..8e214273de 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt @@ -47,8 +47,11 @@ import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.priceFormat import one.mixin.android.session.Session import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.Fiats +import java.math.BigDecimal @Composable fun PerpetualContent( @@ -67,6 +70,8 @@ fun PerpetualContent( .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val fiatSymbol = Fiats.getSymbol() + val fiatRate = BigDecimal(Fiats.getRate()) val viewModel = hiltViewModel() var markets by remember { mutableStateOf>(emptyList()) } @@ -80,6 +85,7 @@ fun PerpetualContent( val openPositionsPreview = openPositions.take(3) val marketsPreview = markets.take(3) val closedPositionsPreview = closedPositions.take(3) + val totalPnlFiatText = "${fiatSymbol}${BigDecimal.valueOf(totalPnl).multiply(fiatRate).priceFormat()}" LaunchedEffect(Unit) { // Refresh positions from API @@ -145,7 +151,7 @@ fun PerpetualContent( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = String.format("$%.2f", totalPnl), + text = totalPnlFiatText, fontSize = 18.sp, fontWeight = FontWeight.W600, color = MixinAppTheme.colors.textPrimary, @@ -153,13 +159,13 @@ fun PerpetualContent( Spacer(modifier = Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = String.format("$%.2f", totalPnl), + text = totalPnlFiatText, fontSize = 14.sp, color = if (totalPnl >= 0) risingColor else fallingColor, ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = String.format("(%s%.1f%%)", if (totalPnl >= 0) "+" else "", totalPnl), + text = String.format("(%s%.2f%%)", if (totalPnl >= 0) "+" else "", totalPnl), fontSize = 14.sp, color = if (totalPnl >= 0) risingColor else fallingColor, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt index 64d8c74569..5a15a20fc5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt @@ -83,9 +83,10 @@ class PerpsActivity : BaseActivity() { } else -> { - MarketDetailPage( + PerpsMarketDetailPage( marketId = marketId, marketSymbol = marketSymbol, + displaySymbol = displaySymbol, onBack = { finish() } ) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt index ee81bc47ec..804f7b1bab 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt @@ -348,7 +348,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } val formattedRoe = try { - String.format("%.2f", latestRoe.toDouble()) + String.format("%f", latestRoe.toDouble()) } catch (e: Exception) { latestRoe } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt index 83fc6f3226..19c9afd887 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt @@ -19,11 +19,13 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay import kotlinx.coroutines.launch import one.mixin.android.BuildConfig +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.databinding.ActivityPerpsPositionShareBinding import one.mixin.android.extension.blurBitmap +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.dp import one.mixin.android.extension.generateQRCode import one.mixin.android.extension.getClipboardManager @@ -38,6 +40,7 @@ import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseActivity import one.mixin.android.ui.web.getScreenshot import one.mixin.android.ui.web.refreshScreenshot +import one.mixin.android.vo.Fiats import java.io.File import java.io.FileOutputStream import java.math.BigDecimal @@ -88,6 +91,10 @@ class PerpsPositionShareActivity : BaseActivity() { } } + private val quoteColorReversed: Boolean by lazy { + defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityPerpsPositionShareBinding.inflate(layoutInflater) @@ -215,16 +222,17 @@ class PerpsPositionShareActivity : BaseActivity() { binding.sideTagTv.text = "${if (isLong) getString(R.string.Long) else getString(R.string.Short)} $tokenSymbol".trim() binding.leverageTagTv.text = getString(R.string.Perpetual_Leverage_Format, leverage) + val useProfitStyle = if (quoteColorReversed) !isProfit else isProfit binding.topCard.setBackgroundResource( - if (isProfit) R.drawable.bg_perps_share_card_profit else R.drawable.bg_perps_share_card_loss + if (useProfitStyle) R.drawable.bg_perps_share_card_profit else R.drawable.bg_perps_share_card_loss ) binding.trendImage.setImageResource( if (isProfit) R.drawable.ic_perps_profit else R.drawable.ic_perps_loss ) - binding.entryValueTv.text = formatUsd(entryPrice) + binding.entryValueTv.text = formatFiat(entryPrice) binding.latestLabelTv.text = latestLabel - binding.latestValueTv.text = formatUsd(latestPrice) + binding.latestValueTv.text = formatFiat(latestPrice) } private fun bindFooter() { @@ -325,9 +333,10 @@ class PerpsPositionShareActivity : BaseActivity() { return "$sign$number%" } - private fun formatUsd(value: String?): String { + private fun formatFiat(value: String?): String { val price = value.toBigDecimalSafely() ?: BigDecimal.ZERO - return "$${price.priceFormat()}" + val fiatPrice = price.multiply(BigDecimal(Fiats.getRate())) + return "${Fiats.getSymbol()}${fiatPrice.priceFormat()}" } private fun String?.toBigDecimalSafely(): BigDecimal? { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt index 88be9b5874..18559f5569 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt @@ -7,7 +7,9 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.Constants import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.isNightMode import one.mixin.android.api.response.perps.PerpsPositionItem @@ -41,6 +43,10 @@ class PositionDetailFragment : BaseFragment() { private val viewModel by viewModels() + private val quoteColorReversed: Boolean by lazy { + requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -57,6 +63,7 @@ class PositionDetailFragment : BaseFragment() { if (position != null) { PositionDetailPage( position = position, + quoteColorReversed = quoteColorReversed, pop = { activity?.onBackPressedDispatcher?.onBackPressed() }, @@ -70,6 +77,7 @@ class PositionDetailFragment : BaseFragment() { } else if (positionHistory != null) { PositionDetailPage( positionHistory = positionHistory, + quoteColorReversed = quoteColorReversed, pop = { activity?.onBackPressedDispatcher?.onBackPressed() }, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt index db4f029dd4..df053d6761 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt @@ -35,7 +35,9 @@ import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.priceFormat import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.Fiats import java.math.BigDecimal import java.text.SimpleDateFormat import java.util.Locale @@ -43,6 +45,7 @@ import java.util.Locale @Composable fun PositionDetailPage( position: PerpsPositionItem, + quoteColorReversed: Boolean = false, pop: () -> Unit, onClose: (() -> Unit)? = null, onShare: (() -> Unit)? = null, @@ -67,7 +70,9 @@ fun PositionDetailPage( } val isProfit = pnl >= BigDecimal.ZERO - val pnlColor = if (isProfit) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val pnlColor = if (isProfit) risingColor else fallingColor val sideText = if (position.side.lowercase() == "long") { stringResource(R.string.Long) @@ -79,6 +84,12 @@ fun PositionDetailPage( val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO val orderValue = quantity * markPrice + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + + fun formatFiat(value: BigDecimal): String { + return "$fiatSymbol${value.multiply(fiatRate).priceFormat()}" + } PageScaffold( title = title, @@ -218,15 +229,15 @@ fun PositionDetailPage( PositionDetailItem( label = stringResource(R.string.Order_Value).uppercase(), - value = "${String.format("%.4f", quantity)} ${position.tokenSymbol ?: ""}", - subtitle = String.format("$%f", orderValue) + value = "${String.format("%f", quantity)} ${position.tokenSymbol ?: ""}", + subtitle = formatFiat(orderValue) ) Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( label = stringResource(R.string.Entry_Price).uppercase(), - value = String.format("$%f", position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + value = formatFiat(position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) ) Spacer(modifier = Modifier.height(20.dp)) @@ -308,6 +319,7 @@ private fun PositionDetailItem( @Composable fun PositionDetailPage( positionHistory: PerpsPositionHistoryItem, + quoteColorReversed: Boolean = false, pop: () -> Unit, onTradeAgain: (() -> Unit)? = null, onShare: (() -> Unit)? = null, @@ -332,7 +344,9 @@ fun PositionDetailPage( } val isProfit = pnl >= BigDecimal.ZERO - val pnlColor = if (isProfit) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val pnlColor = if (isProfit) risingColor else fallingColor val sideText = if (positionHistory.side.lowercase() == "long") { stringResource(R.string.Long) @@ -344,6 +358,12 @@ fun PositionDetailPage( val quantity = positionHistory.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val closePrice = positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO val orderValue = quantity * closePrice + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + + fun formatFiat(value: BigDecimal): String { + return "$fiatSymbol${value.multiply(fiatRate).priceFormat()}" + } PageScaffold( title = title, @@ -378,7 +398,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) Text( - text = String.format("$%f", pnl.abs()), + text = formatFiat(pnl.abs()), fontSize = 24.sp, fontWeight = FontWeight.W500, color = pnlColor, @@ -471,22 +491,22 @@ fun PositionDetailPage( PositionDetailItem( label = stringResource(R.string.Order_Value).uppercase(), - value = "${String.format("%.4f", quantity)} ${positionHistory.tokenSymbol ?: ""}", - subtitle = String.format("$%f", orderValue) + value = "${String.format("%f", quantity)} ${positionHistory.tokenSymbol ?: ""}", + subtitle = formatFiat(orderValue) ) Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( label = stringResource(R.string.Entry_Price).uppercase(), - value = String.format("$%f", positionHistory.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + value = formatFiat(positionHistory.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) ) Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( label = stringResource(R.string.Close_Price).uppercase(), - value = String.format("$%f", positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + value = formatFiat(positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) ) Spacer(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index de19292c1c..aaa9e4abf5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -8,12 +8,15 @@ import androidx.annotation.AttrRes import androidx.annotation.StringRes import androidx.recyclerview.widget.RecyclerView import one.mixin.android.R +import one.mixin.android.extension.priceFormat +import one.mixin.android.vo.Fiats import java.math.BigDecimal class TotalPositionValueAdapter : RecyclerView.Adapter() { private var totalValue: BigDecimal = BigDecimal.ZERO private var subValue: BigDecimal = BigDecimal.ZERO private var subPercent: BigDecimal = BigDecimal.ZERO + private var isClosed: Boolean = false @StringRes private var titleResId: Int = R.string.Total_Position_Value @@ -30,6 +33,7 @@ class TotalPositionValueAdapter : RecyclerView.Adapter= BigDecimal.ZERO + valueTv.setTextColor( + if (isProfit) { + resolveAttrColor(itemView, R.color.wallet_green) + } else { + resolveAttrColor(itemView, R.color.wallet_red) + } + ) + subtitleTv.setTextColor( + if (isProfit) { + resolveAttrColor(itemView, R.color.wallet_green) + } else { + resolveAttrColor(itemView, R.color.wallet_red) + } + ) + } else { + valueTv.setTextColor(resolveAttrColor(itemView, R.attr.text_primary)) + subtitleTv.setTextColor(resolveAttrColor(itemView, R.attr.text_assist)) + } + subtitleTv.text = context.getString( R.string.Perpetual_Amount_Percent_Format, - formatSignedUsd(context, subtitleValue), + formatSignedUsd(subtitleValue), subtitlePercent.toDouble() ) - subtitleTv.setTextColor(resolveAttrColor(itemView, R.attr.text_assist)) } - private fun formatSignedUsd(context: android.content.Context, amount: BigDecimal): String { - val sign = when { - amount < BigDecimal.ZERO -> "-" - else -> "" + private fun formatSignedUsd(amount: BigDecimal): String { + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + val fiatAmount = amount.abs().multiply(fiatRate).priceFormat() + return if (amount < BigDecimal.ZERO) { + "-$fiatSymbol$fiatAmount" + } else { + "$fiatSymbol$fiatAmount" } - return context.getString(R.string.Perpetual_Usd_Amount_Signed, sign, amount.abs().toDouble()) } private fun resolveAttrColor(view: View, @AttrRes attr: Int): Int { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt index 0f5f1f3c6b..c22a8fcd29 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt @@ -28,7 +28,7 @@ import one.mixin.android.extension.navigate import one.mixin.android.extension.numberFormat2 import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.numberFormatCompact -import one.mixin.android.extension.priceFormat2 +import one.mixin.android.extension.priceFormat import one.mixin.android.extension.setQuoteText import one.mixin.android.extension.setQuoteTextWithBackgroud import one.mixin.android.extension.toast @@ -308,7 +308,7 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) { titleView.rightExtraIb.isVisible = true assetSymbol.text = info.symbol assetName.text = info.name - assetRank.text = "#${info.marketCapRank}" + assetRank.text = getString(R.string.Market_Cap_Rank, info.marketCapRank) currentPrice = priceFormat(info.currentPrice) priceValue.text = currentPrice marketHigh.text = priceFormat(info.high24h) @@ -417,16 +417,16 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) { if (price == BigDecimal.ZERO) { "≈ ${Fiats.getSymbol()}0.00" } else { - "≈ ${Fiats.getSymbol()}${price.numberFormat2()}" + "≈ ${Fiats.getSymbol()}${price.priceFormat()}" } } catch (_: NumberFormatException) { - "≈ ${Fiats.getSymbol()}${price.numberFormat2()}" + "≈ ${Fiats.getSymbol()}${price.priceFormat()}" } priceRise.visibility = VISIBLE currentRise = "${(BigDecimal(marketItem.priceChangePercentage24H)).numberFormat2()}%" if (balances != BigDecimal.ZERO && marketItem.priceChangePercentage24H.isNotEmpty()) { val change = changeUsd.multiply(balances).multiply(BigDecimal(Fiats.getRate())) - balanceChange.setQuoteText("${if (change >= BigDecimal.ZERO) "+" else "-"}${Fiats.getSymbol()}${change.priceFormat2().replace("-", "")} ($currentRise)", isPositive) + balanceChange.setQuoteText("${if (change >= BigDecimal.ZERO) "+" else "-"}${Fiats.getSymbol()}${change.abs().priceFormat()} ($currentRise)", isPositive) riseTitle.isVisible = true } else { balanceChange.setTextColor(requireContext().colorAttr(R.attr.text_assist)) @@ -511,4 +511,4 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) { private var currentPrice: String? = null private var currentRise: String? = null -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/item_closed_position_list.xml b/app/src/main/res/layout/item_closed_position_list.xml index 77c82e800e..ef6415836d 100644 --- a/app/src/main/res/layout/item_closed_position_list.xml +++ b/app/src/main/res/layout/item_closed_position_list.xml @@ -45,7 +45,6 @@ android:maxLines="1" android:textColor="?attr/text_primary" android:textSize="14sp" - android:textStyle="bold" tools:text="Long BTC" /> diff --git a/app/src/main/res/layout/item_total_position_value.xml b/app/src/main/res/layout/item_total_position_value.xml index 22d7db127e..ad069496c0 100644 --- a/app/src/main/res/layout/item_total_position_value.xml +++ b/app/src/main/res/layout/item_total_position_value.xml @@ -16,7 +16,7 @@ android:layout_height="wrap_content" android:text="@string/Total_Position_Value" android:textColor="?attr/text_assist" - android:textSize="14sp" /> + android:textSize="13sp" /> + android:textSize="18sp" + android:textFontWeight="500" /> + android:textSize="13sp" /> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1b7233ca3d..dbc9391138 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1723,6 +1723,7 @@ 我们定期更新应用程序,以便为您提供更好的服务 立即更新 市值 + #%1$d 全球总市值 该签名请求来自 %1$s。 拒绝 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0338a4908e..83f1afef69 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1766,6 +1766,7 @@ We update the app regularly so we can make it better for you Update Now Market Cap + #%1$d Market Cap Approve Reject From 6330bccdc8a06937e2fde57ceb37a5679fdbec58 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 11:52:16 +0800 Subject: [PATCH 029/105] Move package and update --- app/src/main/AndroidManifest.xml | 4 +- .../android/ui/home/web3/trade/CandleChart.kt | 2 +- .../home/web3/trade/MarketListBottomSheet.kt | 159 ------------------ .../ui/home/web3/trade/TradeFragment.kt | 8 +- .../android/ui/home/web3/trade/TradePage.kt | 1 + .../{ => perps}/AllPerpsMarketsFragment.kt | 27 +-- .../trade/{ => perps}/AllPositionsFragment.kt | 16 +- .../LeverageBottomSheetDialogFragment.kt | 6 +- .../trade/{ => perps}/OpenPositionAdapter.kt | 5 +- .../trade/{ => perps}/OpenPositionItem.kt | 2 +- .../trade/{ => perps}/OpenPositionPage.kt | 17 +- .../trade/{ => perps}/PerpetualContent.kt | 13 +- .../{ => perps}/PerpetualGuideFragment.kt | 2 +- .../trade/{ => perps}/PerpetualGuidePage.kt | 6 +- .../trade/{ => perps}/PerpetualViewModel.kt | 28 ++- .../web3/trade/{ => perps}/PerpsActivity.kt | 2 +- .../PerpsCloseBottomSheetDialogFragment.kt | 12 +- .../PerpsConfirmBottomSheetDialogFragment.kt | 14 +- .../PerpsMarketDetailPage.kt} | 128 ++++++++------ .../PerpsMarketItem.kt} | 4 +- .../PerpsMarketListAdapter.kt} | 6 +- ...rpsMarketListBottomSheetDialogFragment.kt} | 15 +- .../{ => perps}/PerpsPositionShareActivity.kt | 5 +- .../{ => perps}/PositionDetailFragment.kt | 2 +- .../trade/{ => perps}/PositionDetailPage.kt | 20 +-- 25 files changed, 199 insertions(+), 305 deletions(-) delete mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/AllPerpsMarketsFragment.kt (90%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/AllPositionsFragment.kt (92%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/LeverageBottomSheetDialogFragment.kt (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/OpenPositionAdapter.kt (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/OpenPositionItem.kt (99%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/OpenPositionPage.kt (97%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PerpetualContent.kt (97%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PerpetualGuideFragment.kt (95%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PerpetualGuidePage.kt (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PerpetualViewModel.kt (95%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PerpsActivity.kt (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PerpsCloseBottomSheetDialogFragment.kt (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PerpsConfirmBottomSheetDialogFragment.kt (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{MarketDetailPage.kt => perps/PerpsMarketDetailPage.kt} (82%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{MarketItem.kt => perps/PerpsMarketItem.kt} (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{MarketListAdapter.kt => perps/PerpsMarketListAdapter.kt} (95%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{MarketListBottomSheetDialogFragment.kt => perps/PerpsMarketListBottomSheetDialogFragment.kt} (87%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PerpsPositionShareActivity.kt (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PositionDetailFragment.kt (98%) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/{ => perps}/PositionDetailPage.kt (96%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eef129b319..c8406a3fd2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -167,7 +167,7 @@ android:theme="@style/AppTheme.NoActionBar" android:windowSoftInputMode="adjustResize|stateAlwaysHidden" /> @@ -341,7 +341,7 @@ android:theme="@style/AppTheme.Blur" android:windowSoftInputMode="stateAlwaysHidden|adjustResize" /> diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index c7c172a76d..2d848ff90b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.remember 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.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -48,6 +47,7 @@ import one.mixin.android.Constants import one.mixin.android.api.response.perps.CandleView import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.ui.home.web3.trade.perps.PerpetualViewModel import java.math.BigDecimal import kotlin.math.max import kotlin.math.min diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt deleted file mode 100644 index 543ee9020b..0000000000 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheet.kt +++ /dev/null @@ -1,159 +0,0 @@ -package one.mixin.android.ui.home.web3.trade - -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -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.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import one.mixin.android.R -import one.mixin.android.api.response.perps.PerpsMarket -import one.mixin.android.compose.CoilImage -import one.mixin.android.compose.theme.MixinAppTheme -import one.mixin.android.extension.numberFormatCompact -import java.math.BigDecimal - -@Composable -fun MarketListBottomSheetContent( - markets: List, - onMarketClick: (PerpsMarket) -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) - .padding(16.dp) - ) { - Text( - text = "Select Market", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary, - modifier = Modifier.padding(bottom = 16.dp) - ) - - LazyColumn( - modifier = Modifier.fillMaxWidth() - ) { - items(markets) { market -> - MarketListItem( - market = market, - onClick = { onMarketClick(market) } - ) - Spacer(modifier = Modifier.height(12.dp)) - } - } - } -} - -@Composable -private fun MarketListItem( - market: PerpsMarket, - onClick: () -> Unit -) { - val change = try { - BigDecimal(market.change) - } catch (e: Exception) { - BigDecimal.ZERO - } - - val isPositive = change >= BigDecimal.ZERO - val changeColor = if (isPositive) Color(0xFF4CAF50) else Color(0xFFF44336) - val changeText = "${if (isPositive) "+" else ""}${market.change}%" - - val formattedPrice = try { - val price = BigDecimal(market.markPrice) - if (price >= BigDecimal("1000")) { - String.format("%.2f", price) - } else if (price >= BigDecimal("1")) { - String.format("%.4f", price) - } else { - String.format("%.6f", price) - } - } catch (e: Exception) { - market.markPrice - } - - val formattedVolume = try { - BigDecimal(market.volume).numberFormatCompact() - } catch (e: Exception) { - market.volume - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - CoilImage( - model = market.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - - Spacer(modifier = Modifier.width(12.dp)) - - Column { - Text( - text = market.displaySymbol, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "Vol $formattedVolume", - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist, - ) - } - } - - Column( - horizontalAlignment = Alignment.End - ) { - Text( - text = "$$formattedPrice", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary, - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = changeText, - fontSize = 12.sp, - color = changeColor, - fontWeight = FontWeight.Medium - ) - } - } -} 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 1c56bc6f24..cae791668d 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 @@ -69,6 +69,12 @@ import one.mixin.android.job.MixinJobManager import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseFragment import one.mixin.android.ui.home.web3.GasCheckBottomSheetDialogFragment +import one.mixin.android.ui.home.web3.trade.perps.AllPerpsMarketsFragment +import one.mixin.android.ui.home.web3.trade.perps.AllPositionsFragment +import one.mixin.android.ui.home.web3.trade.perps.PerpetualGuideFragment +import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity +import one.mixin.android.ui.home.web3.trade.perps.PerpsMarketListBottomSheetDialogFragment +import one.mixin.android.ui.home.web3.trade.perps.PositionDetailFragment import one.mixin.android.ui.wallet.AllOrdersFragment import one.mixin.android.ui.wallet.DepositFragment import one.mixin.android.ui.wallet.LimitTransferBottomSheetDialogFragment @@ -334,7 +340,7 @@ class TradeFragment : BaseFragment() { navigateUp(navController) }, onShowMarketList = { isLong -> - MarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, MarketListBottomSheetDialogFragment.TAG) + PerpsMarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, PerpsMarketListBottomSheetDialogFragment.TAG) }, onShowAllMarkets = { navTo(AllPerpsMarketsFragment.newInstance(), AllPerpsMarketsFragment.TAG) 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 6441489e8d..43ad12b61c 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 @@ -61,6 +61,7 @@ import one.mixin.android.extension.openUrl import one.mixin.android.session.Session import one.mixin.android.ui.components.TabItem import one.mixin.android.ui.home.web3.components.OutlinedTab +import one.mixin.android.ui.home.web3.trade.perps.PerpetualContent import one.mixin.android.vo.WalletCategory import java.math.BigDecimal diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt similarity index 90% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt index 3d73c622a3..e0eab32daf 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPerpsMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt @@ -1,6 +1,10 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import PageScaffold +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,9 +27,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.app.viewModels +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @@ -37,6 +43,7 @@ import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.isNightMode import one.mixin.android.extension.toast import one.mixin.android.ui.common.BaseFragment +import one.mixin.android.ui.home.web3.trade.SwapViewModel import one.mixin.android.vo.market.MarketItem @AndroidEntryPoint @@ -51,10 +58,10 @@ class AllPerpsMarketsFragment : BaseFragment() { private val swapViewModel by viewModels() override fun onCreateView( - inflater: android.view.LayoutInflater, - container: android.view.ViewGroup?, - savedInstanceState: android.os.Bundle?, - ): android.view.View { + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { return ComposeView(inflater.context).apply { setContent { MixinAppTheme(darkTheme = context.isNightMode()) { @@ -74,7 +81,7 @@ class AllPerpsMarketsFragment : BaseFragment() { private suspend fun showMarketDetails(market: PerpsMarket) { val marketItem = findMarketItemByPerpsMarket(market) if (marketItem != null && activity != null) { - PerpsActivity.showDetail( + PerpsActivity.Companion.showDetail( requireContext(), market.marketId, market.symbol, @@ -118,7 +125,7 @@ private fun AllMarketsPage( val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) - val viewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel() + val viewModel = hiltViewModel() var markets by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } @@ -137,7 +144,7 @@ private fun AllMarketsPage( } PageScaffold( - title = androidx.compose.ui.res.stringResource(R.string.Markets), + title = stringResource(R.string.Markets), verticalScrollable = false, pop = pop ) { @@ -170,7 +177,7 @@ private fun AllMarketsPage( contentAlignment = Alignment.Center ) { Text( - text = androidx.compose.ui.res.stringResource(R.string.No_Markets), + text = stringResource(R.string.No_Markets), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, ) @@ -187,7 +194,7 @@ private fun AllMarketsPage( item { Spacer(modifier = Modifier.height(8.dp)) } items(markets, key = { it.marketId }) { market -> Column(modifier = Modifier.fillMaxWidth()) { - MarketItem( + PerpsMarketItem( market = market, quoteColorReversed = quoteColorReversed, onClick = { onMarketClick(market) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt similarity index 92% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index e0ff21ef9c..38c6c98478 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.os.Bundle import android.view.View @@ -20,6 +20,8 @@ import one.mixin.android.databinding.FragmentAllClosedPositionsBinding import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseFragment +import one.mixin.android.ui.home.web3.trade.ClosedPositionAdapter +import one.mixin.android.ui.home.web3.trade.TotalPositionValueAdapter import one.mixin.android.util.viewBinding import java.math.BigDecimal @@ -54,7 +56,11 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions OpenPositionAdapter(isQuoteColorReversed) { position -> activity?.supportFragmentManager?.let { fm -> fm.beginTransaction() - .add(android.R.id.content, PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) + .add( + android.R.id.content, + PositionDetailFragment.Companion.newInstance(position), + PositionDetailFragment.Companion.TAG + ) .addToBackStack(null) .commit() } @@ -65,7 +71,11 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions ClosedPositionAdapter(isQuoteColorReversed) { position -> activity?.supportFragmentManager?.let { fm -> fm.beginTransaction() - .add(android.R.id.content, PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) + .add( + android.R.id.content, + PositionDetailFragment.Companion.newInstance(position), + PositionDetailFragment.Companion.TAG + ) .addToBackStack(null) .commit() } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt index eb75f30dc3..ac7acbf4d8 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LeverageBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.annotation.SuppressLint import android.app.Dialog @@ -282,13 +282,13 @@ private fun ProfitLossInfo( Text( text = stringResource(R.string.Price_Up_Profit, "1", "0.0", "$0.00"), fontSize = 13.sp, - color = MixinAppTheme.colors.walletGreen + color = MixinAppTheme.colors.textAssist ) Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.Price_Down_Loss, String.format("%.2f", 100.0 / leverage), "$0.00", ""), fontSize = 13.sp, - color = MixinAppTheme.colors.walletRed + color = MixinAppTheme.colors.textAssist ) } return diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt index 6f7836fbcc..33041421fa 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt @@ -1,5 +1,6 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps +import android.util.TypedValue import android.view.View import android.view.LayoutInflater import android.view.ViewGroup @@ -137,7 +138,7 @@ class OpenPositionAdapter( } private fun resolveAttrColor(view: View, @AttrRes attr: Int): Int { - val typedValue = android.util.TypedValue() + val typedValue = TypedValue() view.context.theme.resolveAttribute(attr, typedValue, true) return if (typedValue.resourceId != 0) { view.context.getColor(typedValue.resourceId) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt similarity index 99% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt index bfc0209d91..9cd62b527d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt similarity index 97% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt index af68ff6a68..5c4ea62f6a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/OpenPositionPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import PageScaffold import androidx.compose.foundation.background @@ -16,7 +16,6 @@ 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.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -25,19 +24,13 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.Slider -import androidx.compose.material.SliderDefaults import androidx.compose.material.Text -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -54,7 +47,6 @@ 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 kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket @@ -65,10 +57,12 @@ import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.priceFormat import one.mixin.android.extension.putInt import one.mixin.android.session.Session +import one.mixin.android.ui.home.web3.trade.InputContent import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import one.mixin.android.vo.safe.TokenItem import java.math.BigDecimal +import java.math.RoundingMode import kotlin.math.abs private fun getLeveragePrefKey(marketId: String) = "pref_perps_leverage_$marketId" @@ -307,6 +301,7 @@ fun OpenPositionPage( ) { Text( modifier = Modifier.padding(horizontal = 10.dp).widthIn(min = 20.dp), + textAlign = TextAlign.Center, text = displayText, fontSize = 12.sp, color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textPrimary @@ -533,7 +528,7 @@ private fun calculateOrderValue(amount: String, leverage: Float, price: String): return "0" } - val orderValue = (amountValue * BigDecimal(leverage.toDouble())).divide(priceValue, 8, java.math.RoundingMode.HALF_UP) + val orderValue = (amountValue * BigDecimal(leverage.toDouble())).divide(priceValue, 8, RoundingMode.HALF_UP) val result = orderValue.stripTrailingZeros().toPlainString() return result @@ -554,7 +549,7 @@ private fun calculateLiquidationPrice( } val liquidationPercent = BigDecimal(100.0 / leverage) - val liquidationRatio = liquidationPercent.divide(BigDecimal(100), 8, java.math.RoundingMode.HALF_UP) + val liquidationRatio = liquidationPercent.divide(BigDecimal(100), 8, RoundingMode.HALF_UP) val liquidationPrice = if (isLong) { price * (BigDecimal.ONE - liquidationRatio) } else { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt similarity index 97% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt index 8e214273de..53156a9b4a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -49,6 +49,7 @@ import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.priceFormat import one.mixin.android.session.Session +import one.mixin.android.ui.home.web3.trade.ClosedPositionItem import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal @@ -241,7 +242,9 @@ fun PerpetualContent( } } else { openPositionsPreview.forEach { position -> - OpenPositionItem(position = position, onClick = { onOpenPositionClick(position) }) + OpenPositionItem( + position = position, + onClick = { onOpenPositionClick(position) }) Spacer(modifier = Modifier.height(12.dp)) } @@ -312,7 +315,7 @@ fun PerpetualContent( } } else { marketsPreview.forEach { market -> - MarketItem( + PerpsMarketItem( market = market, quoteColorReversed = quoteColorReversed, onClick = { @@ -398,7 +401,9 @@ fun PerpetualContent( } } else { closedPositionsPreview.forEach { position -> - ClosedPositionItem(position = position, onClick = { onClosedPositionClick(position) }) + ClosedPositionItem( + position = position, + onClick = { onClosedPositionClick(position) }) Spacer(modifier = Modifier.height(12.dp)) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuideFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt similarity index 95% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuideFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt index 928f37a28e..aebf46ae37 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuideFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.os.Bundle import android.view.LayoutInflater diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuidePage.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 326782137e..87a799f1ab 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -1,7 +1,6 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import PageScaffold -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,9 +11,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Divider import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -23,7 +20,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt similarity index 95% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt index 1b50558007..5790575350 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpetualViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -10,29 +10,35 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.Constants +import one.mixin.android.api.request.perps.CloseOrderRequest import one.mixin.android.api.response.perps.CandleView import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.service.RouteService import one.mixin.android.api.request.perps.OpenOrderRequest import one.mixin.android.api.request.perps.OpenOrderResponse import one.mixin.android.api.response.perps.PerpsPosition -import one.mixin.android.api.response.perps.PerpsPositionHistory import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.db.TokenDao import one.mixin.android.db.perps.PerpsPositionDao import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.db.perps.PerpsPositionHistoryDao import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshPerpsPositionsJob import one.mixin.android.vo.safe.TokenItem import timber.log.Timber +import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import javax.inject.Inject @HiltViewModel class PerpetualViewModel @Inject constructor( private val routeService: RouteService, - private val tokenDao: one.mixin.android.db.TokenDao, + private val tokenDao: TokenDao, private val perpsPositionDao: PerpsPositionDao, - private val perpsPositionHistoryDao: one.mixin.android.db.perps.PerpsPositionHistoryDao, + private val perpsPositionHistoryDao: PerpsPositionHistoryDao, private val perpsMarketDao: PerpsMarketDao, private val jobManager: MixinJobManager ) : ViewModel() { @@ -150,10 +156,10 @@ class PerpetualViewModel @Inject constructor( viewModelScope.launch { try { val usdTokens = withContext(Dispatchers.IO) { - val usdIds = one.mixin.android.Constants.usdIds + val usdIds = Constants.usdIds tokenDao.findTokenItems(usdIds) .sortedByDescending { - it.balance.toBigDecimalOrNull() ?: java.math.BigDecimal.ZERO + it.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO } } onSuccess(usdTokens) @@ -212,8 +218,12 @@ class PerpetualViewModel @Inject constructor( unrealizedPnl = "0", roe = "0", walletId = walletId, - createdAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()), - updatedAt = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).format(java.util.Date()) + createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format( + Date() + ), + updatedAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format( + Date() + ) ) withContext(Dispatchers.IO) { @@ -389,7 +399,7 @@ class PerpetualViewModel @Inject constructor( ) { viewModelScope.launch { try { - val request = one.mixin.android.api.request.perps.CloseOrderRequest( + val request = CloseOrderRequest( positionId = positionId ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt index 5a15a20fc5..fd0eee6daa 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.content.Context import android.content.Intent diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt index 804f7b1bab..5abcd03812 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.annotation.SuppressLint import android.app.Dialog @@ -26,6 +26,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -37,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -238,18 +240,18 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } Step.Error -> { - androidx.compose.material.Icon( + Icon( modifier = Modifier.size(70.dp), - painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_transfer_status_failed), + painter = painterResource(id = R.drawable.ic_transfer_status_failed), contentDescription = null, tint = Color.Unspecified, ) } Step.Done -> { - androidx.compose.material.Icon( + Icon( modifier = Modifier.size(70.dp), - painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_transfer_status_success), + painter = painterResource(id = R.drawable.ic_transfer_status_success), contentDescription = null, tint = Color.Unspecified, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt index 6773b9b100..2ad4b2fcd9 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsConfirmBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt @@ -1,10 +1,9 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.annotation.SuppressLint import android.app.Dialog import android.content.DialogInterface import android.net.Uri -import android.os.Bundle import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -28,6 +27,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -39,6 +39,7 @@ 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.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -74,7 +75,6 @@ import one.mixin.android.ui.common.biometric.buildTransferBiometricItem import one.mixin.android.ui.home.web3.components.ActionBottom import one.mixin.android.ui.tip.wc.compose.ItemWalletContent import one.mixin.android.ui.wallet.ItemUserContent -import one.mixin.android.ui.wallet.SwapTransferBottomSheetDialogFragment import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager import one.mixin.android.vo.User @@ -234,18 +234,18 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm } Step.Error -> { - androidx.compose.material.Icon( + Icon( modifier = Modifier.size(70.dp), - painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_transfer_status_failed), + painter = painterResource(id = R.drawable.ic_transfer_status_failed), contentDescription = null, tint = Color.Unspecified, ) } Step.Done -> { - androidx.compose.material.Icon( + Icon( modifier = Modifier.size(70.dp), - painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_transfer_status_success), + painter = painterResource(id = R.drawable.ic_transfer_status_success), contentDescription = null, tint = Color.Unspecified, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt similarity index 82% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 55571e6dea..fd51083ccc 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import PageScaffold import androidx.compose.foundation.Image @@ -42,6 +42,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.launch import one.mixin.android.Constants @@ -52,8 +53,12 @@ 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.marketPriceFormat +import one.mixin.android.extension.priceFormat import one.mixin.android.session.Session +import one.mixin.android.ui.home.web3.trade.CandleChart import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.Fiats import java.math.BigDecimal @Composable @@ -71,8 +76,18 @@ fun PerpsMarketDetailPage( var currentPosition by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() - val timeFrames = listOf("1h", "1d", "1w", "1M") - + val timeFrameValues = listOf("1h", "1d", "1w", "1M") + val timeFrameLabels = listOf( + stringResource(R.string.hours_count_short, 1), + stringResource(R.string.days_count_short, 1), + stringResource(R.string.weeks_count_short, 1), + stringResource(R.string.months_count_short, 1), + ) + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val walletId = Session.getAccountId() ?: "" LaunchedEffect(marketId) { @@ -86,7 +101,7 @@ fun PerpsMarketDetailPage( isLoading = false } ) - + if (walletId.isNotEmpty()) { viewModel.getPositionByMarket(walletId, marketId) { position -> currentPosition = position @@ -120,7 +135,8 @@ fun PerpsMarketDetailPage( marketSymbol = marketSymbol, displaySymbol = displaySymbol, selectedTimeFrame = selectedTimeFrame, - timeFrames = timeFrames, + timeFrameValues = timeFrameValues, + timeFrameLabels = timeFrameLabels, onTimeFrameChange = { index -> coroutineScope.launch { selectedTimeFrame = index } } @@ -158,14 +174,14 @@ fun PerpsMarketDetailPage( .fillMaxWidth() .height(48.dp), onClick = { - val activity = context as? androidx.fragment.app.FragmentActivity ?: return@Button + val activity = context as? FragmentActivity ?: return@Button val position = currentPosition?.toPosition() ?: return@Button - - PerpsCloseBottomSheetDialogFragment.newInstance( + + PerpsCloseBottomSheetDialogFragment.Companion.newInstance( position = position, ).setOnDone { currentPosition = null - }.show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + }.show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.Companion.TAG) }, colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MixinAppTheme.colors.accent @@ -195,7 +211,7 @@ fun PerpsMarketDetailPage( .weight(1f) .height(48.dp), onClick = { - PerpsActivity.showOpenPosition( + PerpsActivity.Companion.showOpenPosition( context = context, marketId = marketId, marketSymbol = marketSymbol, @@ -204,7 +220,7 @@ fun PerpsMarketDetailPage( ) }, colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = MixinAppTheme.colors.walletGreen + backgroundColor = risingColor ), shape = RoundedCornerShape(32.dp), elevation = ButtonDefaults.elevation( @@ -227,7 +243,7 @@ fun PerpsMarketDetailPage( .weight(1f) .height(48.dp), onClick = { - PerpsActivity.showOpenPosition( + PerpsActivity.Companion.showOpenPosition( context = context, marketId = marketId, marketSymbol = marketSymbol, @@ -236,7 +252,7 @@ fun PerpsMarketDetailPage( ) }, colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = MixinAppTheme.colors.walletRed + backgroundColor = fallingColor ), shape = RoundedCornerShape(32.dp), elevation = ButtonDefaults.elevation( @@ -267,6 +283,20 @@ private fun MarketInfoCard( market: PerpsMarket, onLearnClick: () -> Unit, ) { + val context = LocalContext.current + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + val fundingRate = market.fundingRate.toBigDecimalOrNull() ?: BigDecimal.ZERO + val fundingColor = when { + fundingRate > BigDecimal.ZERO -> risingColor + fundingRate < BigDecimal.ZERO -> fallingColor + else -> MixinAppTheme.colors.textPrimary + } + Column( modifier = Modifier .fillMaxWidth() @@ -314,7 +344,7 @@ private fun MarketInfoCard( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "$${formatVolume(market.volume)}", + text = formatVolume(market.volume, fiatRate, fiatSymbol), fontSize = 16.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary @@ -331,7 +361,7 @@ private fun MarketInfoCard( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "-", + text = stringResource(R.string.N_A), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary @@ -350,23 +380,22 @@ private fun MarketInfoCard( text = "${market.fundingRate}%", fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary + color = fundingColor ) } } } -private fun formatVolume(volume: String): String { +private fun formatVolume( + volume: String, + fiatRate: BigDecimal, + fiatSymbol: String, +): String { return try { - val vol = BigDecimal(volume) - when { - vol >= BigDecimal("1000000000") -> String.format("%.2fB", vol.divide(BigDecimal("1000000000"))) - vol >= BigDecimal("1000000") -> String.format("%.2fM", vol.divide(BigDecimal("1000000"))) - vol >= BigDecimal("1000") -> String.format("%.2fK", vol.divide(BigDecimal("1000"))) - else -> String.format("%.2f", vol) - } + val fiatVolume = BigDecimal(volume).multiply(fiatRate) + "${fiatSymbol}${fiatVolume.priceFormat()}" } catch (e: Exception) { - volume + "${fiatSymbol}${volume}" } } @@ -376,12 +405,17 @@ private fun MarketDetailCard( marketSymbol: String, displaySymbol: String, selectedTimeFrame: Int, - timeFrames: List, + timeFrameValues: List, + timeFrameLabels: List, onTimeFrameChange: (Int) -> Unit, ) { val context = LocalContext.current - val quoteColorPref = context.defaultSharedPreferences + val quoteColorReversed = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() val change = try { BigDecimal(market.change) @@ -390,30 +424,12 @@ private fun MarketDetailCard( } val isPositive = change >= BigDecimal.ZERO - val changeColor = if (isPositive) { - if (quoteColorPref) { - MixinAppTheme.colors.walletRed - } else { - MixinAppTheme.colors.walletGreen - } - } else { - if (quoteColorPref) { - MixinAppTheme.colors.walletGreen - } else { - MixinAppTheme.colors.walletRed - } - } + val changeColor = if (isPositive) risingColor else fallingColor val changeText = "${if (isPositive) "+" else ""}${market.change}%" val formattedPrice = try { - val price = BigDecimal(market.markPrice) - if (price >= BigDecimal("1000")) { - String.format("%.2f", price) - } else if (price >= BigDecimal("1")) { - String.format("%.4f", price) - } else { - String.format("%.6f", price) - } + val price = BigDecimal(market.markPrice).multiply(fiatRate) + price.marketPriceFormat() } catch (e: Exception) { market.markPrice } @@ -433,7 +449,7 @@ private fun MarketDetailCard( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "$$formattedPrice", + text = "${fiatSymbol}$formattedPrice", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = MixinAppTheme.colors.textPrimary @@ -459,12 +475,14 @@ private fun MarketDetailCard( Spacer(modifier = Modifier.height(16.dp)) - Box(modifier = Modifier - .fillMaxWidth() - .height(200.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + ) { CandleChart( symbol = marketSymbol, - timeFrame = timeFrames[selectedTimeFrame] + timeFrame = timeFrameValues[selectedTimeFrame] ) } @@ -474,7 +492,7 @@ private fun MarketDetailCard( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - timeFrames.forEachIndexed { index, timeFrame -> + timeFrameLabels.forEachIndexed { index, timeFrameLabel -> Box( modifier = Modifier .weight(1f) @@ -491,7 +509,7 @@ private fun MarketDetailCard( contentAlignment = Alignment.Center ) { Text( - text = timeFrame, + text = timeFrameLabel, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = if (selectedTimeFrame == index) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt index 60c664cc5c..4131ea0ac5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,7 +32,7 @@ import one.mixin.android.vo.Fiats import java.math.BigDecimal @Composable -fun MarketItem( +fun PerpsMarketItem( market: PerpsMarket, quoteColorReversed: Boolean = false, onClick: () -> Unit = {} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt similarity index 95% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt index f4e63cae5a..2b48b9d241 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.view.LayoutInflater import android.view.ViewGroup @@ -13,10 +13,10 @@ import one.mixin.android.extension.priceFormat import one.mixin.android.vo.Fiats import java.math.BigDecimal -class MarketListAdapter( +class PerpsMarketListAdapter( private val isQuoteColorReversed: Boolean, private val onMarketClick: (PerpsMarket) -> Unit -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { private var markets = listOf() diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt similarity index 87% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt index a34f7c5e6f..0e78a32dc8 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/MarketListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.annotation.SuppressLint import android.app.Dialog @@ -23,16 +23,17 @@ import one.mixin.android.extension.withArgs import one.mixin.android.ui.common.MixinBottomSheetDialogFragment import one.mixin.android.util.viewBinding import one.mixin.android.widget.BottomSheet +import one.mixin.android.widget.SearchView import javax.inject.Inject @AndroidEntryPoint -class MarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { +class PerpsMarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { companion object { - const val TAG = "MarketListBottomSheetDialogFragment" + const val TAG = "PerpsMarketListBottomSheetDialogFragment" private const val ARGS_IS_LONG = "args_is_long" - fun newInstance(isLong: Boolean) = MarketListBottomSheetDialogFragment().withArgs { + fun newInstance(isLong: Boolean) = PerpsMarketListBottomSheetDialogFragment().withArgs { putBoolean(ARGS_IS_LONG, isLong) } } @@ -42,7 +43,7 @@ class MarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) } private val adapter by lazy { - MarketListAdapter(isQuoteColorReversed) { market -> onMarketClick(market) } + PerpsMarketListAdapter(isQuoteColorReversed) { market -> onMarketClick(market) } } @Inject @@ -74,7 +75,7 @@ class MarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { marketRv.layoutManager = LinearLayoutManager(requireContext()) marketRv.adapter = adapter - searchEt.listener = object : one.mixin.android.widget.SearchView.OnSearchViewListener { + searchEt.listener = object : SearchView.OnSearchViewListener { override fun afterTextChanged(s: Editable?) { filterMarkets(s?.toString() ?: "") } @@ -113,7 +114,7 @@ class MarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } private fun onMarketClick(market: PerpsMarket) { - PerpsActivity.showOpenPosition( + PerpsActivity.Companion.showOpenPosition( context = requireContext(), marketId = market.marketId, marketSymbol = market.symbol, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt index 19c9afd887..301e76df38 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PerpsPositionShareActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt @@ -1,9 +1,10 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.content.ClipData import android.content.Context import android.content.Intent import android.graphics.Bitmap +import android.graphics.Color import android.graphics.RenderEffect import android.graphics.Shader import android.graphics.drawable.BitmapDrawable @@ -113,7 +114,7 @@ class PerpsPositionShareActivity : BaseActivity() { View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN ) - window.statusBarColor = android.graphics.Color.TRANSPARENT + window.statusBarColor = Color.TRANSPARENT binding.content.updateLayoutParams { topMargin = 20.dp diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt similarity index 98% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt index 18559f5569..7c7cdde898 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import android.os.Bundle import android.view.LayoutInflater diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt similarity index 96% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt index df053d6761..c5f3acdbb5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/PositionDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt @@ -1,4 +1,4 @@ -package one.mixin.android.ui.home.web3.trade +package one.mixin.android.ui.home.web3.trade.perps import PageScaffold import androidx.compose.foundation.background @@ -18,8 +18,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -28,6 +26,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R @@ -36,6 +35,7 @@ import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.priceFormat +import one.mixin.android.ui.tip.wc.compose.ItemWalletContent import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal @@ -183,7 +183,7 @@ fun PositionDetailPage( .weight(1f) .clickable { onClose?.invoke() } .padding(vertical = 10.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center + textAlign = TextAlign.Center ) Box( modifier = Modifier @@ -199,7 +199,7 @@ fun PositionDetailPage( .weight(1f) .clickable { onShare?.invoke() } .padding(vertical = 10.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center + textAlign = TextAlign.Center ) } @@ -242,7 +242,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) - one.mixin.android.ui.tip.wc.compose.ItemWalletContent( + ItemWalletContent( title = stringResource(R.string.Wallet).uppercase(), fontSize = 16.sp, padding = 0.dp @@ -445,7 +445,7 @@ fun PositionDetailPage( .weight(1f) .clickable { onTradeAgain?.invoke() } .padding(vertical = 10.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center + textAlign = TextAlign.Center ) Box( modifier = Modifier @@ -461,7 +461,7 @@ fun PositionDetailPage( .weight(1f) .clickable { onShare?.invoke() } .padding(vertical = 10.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center + textAlign = TextAlign.Center ) } @@ -510,8 +510,8 @@ fun PositionDetailPage( ) Spacer(modifier = Modifier.height(20.dp)) - - one.mixin.android.ui.tip.wc.compose.ItemWalletContent( + + ItemWalletContent( title = stringResource(R.string.Wallet).uppercase(), fontSize = 16.sp, padding = 0.dp From 6ced697b0ec89d76fd71c64b470054d76c37009a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 12:40:21 +0800 Subject: [PATCH 030/105] Update position detail --- .codex/environments/environment.toml | 11 + .../home/web3/trade/perps/PerpetualContent.kt | 7 +- .../web3/trade/perps/PerpetualViewModel.kt | 15 + .../web3/trade/perps/PerpsMarketDetailPage.kt | 552 ++++++++++++++---- app/src/main/res/values-zh-rCN/strings.xml | 6 + app/src/main/res/values/strings.xml | 2 + 6 files changed, 471 insertions(+), 122 deletions(-) create mode 100644 .codex/environments/environment.toml diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000000..cd7aeb2fab --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "android" + +[setup] +script = "" + +[[actions]] +name = "运行" +icon = "run" +command = "./gradlew installGooglePlayDebug" 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 53156a9b4a..c20cb610f5 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 @@ -212,7 +212,7 @@ fun PerpetualContent( Icon( painter = painterResource(id = R.drawable.ic_empty_transaction), contentDescription = null, - tint = MixinAppTheme.colors.textAssist, + tint = MixinAppTheme.colors.backgroundGrayLight, modifier = Modifier.size(78.dp) ) Spacer(modifier = Modifier.height(12.dp)) @@ -386,17 +386,20 @@ fun PerpetualContent( Column( horizontalAlignment = Alignment.CenterHorizontally ) { + Spacer(modifier = Modifier.height(16.dp)) Icon( painter = painterResource(id = R.drawable.ic_empty_transaction), contentDescription = null, - tint = MixinAppTheme.colors.textAssist, + tint = MixinAppTheme.colors.backgroundGrayLight, modifier = Modifier.size(78.dp) ) + Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.No_Closed_Positions), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, ) + Spacer(modifier = Modifier.height(16.dp)) } } } else { 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 5790575350..cab7521717 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 @@ -392,6 +392,21 @@ class PerpetualViewModel @Inject constructor( } } + fun getClosedPositionsByMarket(walletId: String, marketId: String, onSuccess: (List) -> Unit) { + viewModelScope.launch { + try { + val allHistories = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getHistories(walletId, 100) + } + val filteredHistories = allHistories.filter { it.productId == marketId } + onSuccess(filteredHistories) + } catch (e: Exception) { + Timber.e(e, "Error loading closed positions by market") + onSuccess(emptyList()) + } + } + } + fun closePerpsOrder( positionId: String, onSuccess: () -> Unit, 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 fd51083ccc..83d9cfc5cf 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 @@ -49,6 +49,7 @@ import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.api.response.perps.toPosition import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme @@ -57,6 +58,7 @@ import one.mixin.android.extension.marketPriceFormat import one.mixin.android.extension.priceFormat import one.mixin.android.session.Session import one.mixin.android.ui.home.web3.trade.CandleChart +import one.mixin.android.ui.home.web3.trade.ClosedPositionItem import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal @@ -74,6 +76,7 @@ fun PerpsMarketDetailPage( var isLoading by remember { mutableStateOf(true) } var selectedTimeFrame by remember { mutableIntStateOf(0) } var currentPosition by remember { mutableStateOf(null) } + var closedPositions by remember { mutableStateOf>(emptyList()) } val coroutineScope = rememberCoroutineScope() val timeFrameValues = listOf("1h", "1d", "1w", "1M") @@ -106,6 +109,10 @@ fun PerpsMarketDetailPage( viewModel.getPositionByMarket(walletId, marketId) { position -> currentPosition = position } + + viewModel.getClosedPositionsByMarket(walletId, marketId) { positions -> + closedPositions = positions + } } } @@ -114,113 +121,152 @@ fun PerpsMarketDetailPage( verticalScrollable = false, pop = onBack ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(16.dp)) - + Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) - .padding(16.dp) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 80.dp) ) { + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + if (market != null) { + MarketDetailCard( + market = market!!, + marketSymbol = marketSymbol, + displaySymbol = displaySymbol, + selectedTimeFrame = selectedTimeFrame, + timeFrameValues = timeFrameValues, + timeFrameLabels = timeFrameLabels, + onTimeFrameChange = { index -> + coroutineScope.launch { selectedTimeFrame = index } + } + ) + } else if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(400.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = MixinAppTheme.colors.accent + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (currentPosition != null) { + OpenPositionCard( + position = currentPosition!!, + onClick = { + val activity = context as? FragmentActivity + val position = currentPosition + if (activity != null && position != null) { + activity.supportFragmentManager + .beginTransaction() + .add( + android.R.id.content, + PositionDetailFragment.newInstance(position), + PositionDetailFragment.TAG + ) + .addToBackStack(null) + .commit() + } + } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + if (market != null) { - MarketDetailCard( + MarketInfoCard( market = market!!, - marketSymbol = marketSymbol, - displaySymbol = displaySymbol, - selectedTimeFrame = selectedTimeFrame, - timeFrameValues = timeFrameValues, - timeFrameLabels = timeFrameLabels, - onTimeFrameChange = { index -> - coroutineScope.launch { selectedTimeFrame = index } + onLearnClick = { + val activity = context as? FragmentActivity ?: return@MarketInfoCard + activity.supportFragmentManager + .beginTransaction() + .add( + android.R.id.content, + PerpetualGuideFragment.newInstance(), + PerpetualGuideFragment.TAG + ) + .addToBackStack(null) + .commit() } ) - } else if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(400.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(40.dp), - color = MixinAppTheme.colors.accent - ) - } } - } - Spacer(modifier = Modifier.height(16.dp)) + if (closedPositions.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + ClosedPositionsSection( + positions = closedPositions, + onViewAll = { + val activity = context as? FragmentActivity ?: return@ClosedPositionsSection + activity.supportFragmentManager + .beginTransaction() + .add( + android.R.id.content, + AllPositionsFragment.newClosedInstance(), + AllPositionsFragment.TAG + ) + .addToBackStack(null) + .commit() + }, + onPositionClick = { position -> + val activity = context as? FragmentActivity ?: return@ClosedPositionsSection + activity.supportFragmentManager + .beginTransaction() + .add( + android.R.id.content, + PositionDetailFragment.newInstance(position), + PositionDetailFragment.TAG + ) + .addToBackStack(null) + .commit() + } + ) + } - if (market != null) { - MarketInfoCard( - market = market!!, - onLearnClick = { /* TODO: Navigate to guide */ } - ) + Spacer(modifier = Modifier.height(16.dp)) } - Spacer(modifier = Modifier.height(16.dp)) - if (market != null) { - if (currentPosition != null) { - Button( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - onClick = { - val activity = context as? FragmentActivity ?: return@Button - val position = currentPosition?.toPosition() ?: return@Button - - PerpsCloseBottomSheetDialogFragment.Companion.newInstance( - position = position, - ).setOnDone { - currentPosition = null - }.show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.Companion.TAG) - }, - colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = MixinAppTheme.colors.accent - ), - shape = RoundedCornerShape(32.dp), - elevation = ButtonDefaults.elevation( - pressedElevation = 0.dp, - defaultElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp - ) - ) { - Text( - text = stringResource(R.string.Close_Position), - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(MixinAppTheme.colors.background) + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp, top = 8.dp) + ) { + if (currentPosition != null) { Button( modifier = Modifier - .weight(1f) + .fillMaxWidth() .height(48.dp), onClick = { - PerpsActivity.Companion.showOpenPosition( - context = context, - marketId = marketId, - marketSymbol = marketSymbol, - marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, - isLong = true - ) + val activity = context as? FragmentActivity ?: return@Button + val position = currentPosition?.toPosition() ?: return@Button + + PerpsCloseBottomSheetDialogFragment.newInstance( + position = position, + ).setOnDone { + currentPosition = null + }.show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) }, colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = risingColor + backgroundColor = MixinAppTheme.colors.accent ), shape = RoundedCornerShape(32.dp), elevation = ButtonDefaults.elevation( @@ -231,49 +277,84 @@ fun PerpsMarketDetailPage( ) ) { Text( - text = stringResource(R.string.Long), + text = stringResource(R.string.Close_Position), fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White ) } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + modifier = Modifier + .weight(1f) + .height(48.dp), + onClick = { + PerpsActivity.showOpenPosition( + context = context, + marketId = marketId, + marketSymbol = marketSymbol, + marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, + isLong = true + ) + }, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = risingColor + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp + ) + ) { + Text( + text = stringResource(R.string.Long), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } - Button( - modifier = Modifier - .weight(1f) - .height(48.dp), - onClick = { - PerpsActivity.Companion.showOpenPosition( - context = context, - marketId = marketId, - marketSymbol = marketSymbol, - marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, - isLong = false + Button( + modifier = Modifier + .weight(1f) + .height(48.dp), + onClick = { + PerpsActivity.showOpenPosition( + context = context, + marketId = marketId, + marketSymbol = marketSymbol, + marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, + isLong = false + ) + }, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = fallingColor + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp ) - }, - colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = fallingColor - ), - shape = RoundedCornerShape(32.dp), - elevation = ButtonDefaults.elevation( - pressedElevation = 0.dp, - defaultElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp - ) - ) { - Text( - text = stringResource(R.string.Short), - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) + ) { + Text( + text = stringResource(R.string.Short), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } } } } } - - Spacer(modifier = Modifier.height(24.dp)) } } } @@ -523,3 +604,234 @@ private fun MarketDetailCard( } } } + +@Composable +private fun OpenPositionCard( + position: PerpsPositionItem, + onClick: () -> Unit, +) { + val context = LocalContext.current + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + + val pnl = position.unrealizedPnl?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val isProfit = pnl >= BigDecimal.ZERO + val pnlColor = if (isProfit) risingColor else fallingColor + + val isLong = position.side.equals("long", ignoreCase = true) + val directionColor = if (isLong) risingColor else fallingColor + + val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO + val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val orderValue = quantity.multiply(markPrice).multiply(fiatRate) + + val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + val liquidationPrice = calculateLiquidationPriceValue(entryPrice, position.leverage, isLong) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .clickable { onClick() } + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = stringResource(R.string.PNL), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "${ + if (isProfit) { + "+" + } else { + "-" + } + }${fiatSymbol}${pnl.abs().multiply(fiatRate).priceFormat()}", + fontSize = 14.sp, + color = pnlColor + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = stringResource(R.string.Direction), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Row { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(directionColor) + .padding(horizontal = 8.dp, vertical = 0.5.dp) + ) { + Text( + text = if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short), + fontSize = 10.sp, + color = Color.White + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${position.leverage}x", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = stringResource(R.string.Order_Value), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${quantity.stripTrailingZeros().toPlainString()} ${position.tokenSymbol}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = stringResource(R.string.Amount), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${fiatSymbol}${orderValue.priceFormat()}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = stringResource(R.string.Entry_Price), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${fiatSymbol}${entryPrice.multiply(fiatRate).priceFormat()}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = stringResource(R.string.Liquidation_Price), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${fiatSymbol}${liquidationPrice.multiply(fiatRate).priceFormat()}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + } + } + } +} + +@Composable +private fun ClosedPositionsSection( + positions: List, + onViewAll: () -> Unit, + onPositionClick: (PerpsPositionHistoryItem) -> Unit, +) { + val displayPositions = positions.take(3) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.Closed_Positions), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + + if (positions.size > 3) { + Text( + text = stringResource(R.string.view_all), + fontSize = 14.sp, + color = MixinAppTheme.colors.accent, + modifier = Modifier.clickable { onViewAll() } + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + displayPositions.forEach { position -> + ClosedPositionItem( + position = position, + onClick = { onPositionClick(position) } + ) + if (position != displayPositions.last()) { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +private fun calculateLiquidationPriceValue( + entryPrice: BigDecimal, + leverage: Int, + isLong: Boolean, +): BigDecimal { + if (entryPrice == BigDecimal.ZERO || leverage == 0) { + return BigDecimal.ZERO + } + + val liquidationPercent = BigDecimal(100.0 / leverage) + val liquidationRatio = liquidationPercent.divide(BigDecimal(100), 8, java.math.RoundingMode.HALF_UP) + + return if (isLong) { + entryPrice.multiply(BigDecimal.ONE.subtract(liquidationRatio)) + } else { + entryPrice.multiply(BigDecimal.ONE.add(liquidationRatio)) + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dbc9391138..1c83030da5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2313,12 +2313,18 @@ 已平仓 暂无持仓 暂无已平仓记录 + 盈亏 + 方向 暂无行情 查看更多 加载中... 成交量 %1$s 开仓: $%1$s → 平仓: $%2$s 永续合约如何运作? + 了解如何交易永续合约 + 24小时成交量 + 未平仓量 + 资金费率 选择市场 搜索… 未检测到sim卡。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 83f1afef69..160c5eb0ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2381,6 +2381,8 @@ Closed Positions No Positions No Closed Positions + PNL + Direction No Markets View More Loading... From 5003b12f47c3d51fd1a992c39b891df2da38224e Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 14:41:26 +0800 Subject: [PATCH 031/105] Refresh timer --- .../android/db/perps/PerpsPositionDao.kt | 18 ++++ .../db/perps/PerpsPositionHistoryDao.kt | 19 +++++ .../web3/trade/TotalPositionValueAdapter.kt | 8 +- .../web3/trade/perps/AllPositionsFragment.kt | 77 +++++++++++++---- .../home/web3/trade/perps/PerpetualContent.kt | 83 +++++++------------ .../web3/trade/perps/PerpetualViewModel.kt | 52 ++++++++++++ 6 files changed, 187 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index 3a2b914c3b..2f86f54519 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.paging.DataSource +import kotlinx.coroutines.flow.Flow import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.db.BaseDao @@ -26,6 +27,17 @@ interface PerpsPositionDao : BaseDao { """) suspend fun getOpenPositions(walletId: String): List + @Query( + """ + SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol + FROM positions p + LEFT JOIN markets m ON m.market_id = p.product_id + WHERE p.wallet_id = :walletId AND p.state = 'open' + ORDER BY p.created_at DESC + """ + ) + fun observeOpenPositions(walletId: String): Flow> + @Query(""" SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p @@ -52,9 +64,15 @@ interface PerpsPositionDao : BaseDao { @Query("SELECT SUM(CAST(unrealized_pnl AS REAL)) FROM positions WHERE wallet_id = :walletId AND state = 'open'") suspend fun getTotalUnrealizedPnl(walletId: String): Double? + @Query("SELECT COALESCE(SUM(CAST(unrealized_pnl AS REAL)), 0) FROM positions WHERE wallet_id = :walletId AND state = 'open'") + fun observeTotalUnrealizedPnl(walletId: String): Flow + @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM positions WHERE wallet_id = :walletId AND state = 'open'") suspend fun getTotalOpenPositionValue(walletId: String): Double? + @Query("SELECT COALESCE(SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))), 0) FROM positions WHERE wallet_id = :walletId AND state = 'open'") + fun observeTotalOpenPositionValue(walletId: String): Flow + @Query("DELETE FROM positions WHERE position_id = :positionId") fun deleteById(positionId: String) } diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt index 413bdffa5f..1b4bfd50dc 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.paging.DataSource +import kotlinx.coroutines.flow.Flow import one.mixin.android.api.response.perps.PerpsPositionHistory import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.db.BaseDao @@ -27,6 +28,18 @@ interface PerpsPositionHistoryDao : BaseDao { """) suspend fun getHistories(walletId: String, limit: Int): List + @Query( + """ + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + FROM position_history h + LEFT JOIN markets m ON m.market_id = h.product_id + WHERE h.wallet_id = :walletId + ORDER BY h.closed_at DESC + LIMIT :limit + """ + ) + fun observeHistories(walletId: String, limit: Int): Flow> + @Query(""" SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol FROM position_history h @@ -50,6 +63,12 @@ interface PerpsPositionHistoryDao : BaseDao { @Query("SELECT SUM(CAST(realized_pnl AS REAL)) FROM position_history WHERE wallet_id = :walletId") suspend fun getTotalRealizedPnl(walletId: String): Double? + @Query("SELECT COALESCE(SUM(CAST(realized_pnl AS REAL)), 0) FROM position_history WHERE wallet_id = :walletId") + fun observeTotalRealizedPnl(walletId: String): Flow + @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM position_history WHERE wallet_id = :walletId") suspend fun getTotalClosedEntryValue(walletId: String): Double? + + @Query("SELECT COALESCE(SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))), 0) FROM position_history WHERE wallet_id = :walletId") + fun observeTotalClosedEntryValue(walletId: String): Flow } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index aaa9e4abf5..f0146b29fa 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -71,16 +71,16 @@ class TotalPositionValueAdapter : RecyclerView.Adapter= BigDecimal.ZERO valueTv.setTextColor( if (isProfit) { - resolveAttrColor(itemView, R.color.wallet_green) + context.getColor(R.color.wallet_green) } else { - resolveAttrColor(itemView, R.color.wallet_red) + context.getColor(R.color.wallet_red) } ) subtitleTv.setTextColor( if (isProfit) { - resolveAttrColor(itemView, R.color.wallet_green) + context.getColor(R.color.wallet_green) } else { - resolveAttrColor(itemView, R.color.wallet_red) + context.getColor(R.color.wallet_red) } ) } else { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index 38c6c98478..ab7d394944 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -5,12 +5,18 @@ import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData +import androidx.lifecycle.Lifecycle import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.paging.PagedList import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R @@ -33,6 +39,8 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions private const val ARGS_INITIAL_TAB = "args_initial_tab" private const val TAB_OPEN = "tab_open" private const val TAB_CLOSED = "tab_closed" + private const val POSITION_REFRESH_INTERVAL_MS = 10_000L + private const val CLOSED_POSITION_REFRESH_LIMIT = 100 fun newInstance(initialOpenTab: Boolean = false) = AllPositionsFragment().apply { arguments = Bundle().apply { @@ -90,6 +98,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions private var currentTab: PositionTab = PositionTab.CLOSED private var openPositionsLiveData: LiveData>? = null private var closedPositionsLiveData: LiveData>? = null + private var totalValueJob: Job? = null private val openPositionsObserver = Observer> { pagedList -> binding.progressBar.isVisible = false @@ -138,11 +147,13 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions } loadPositions() + observePeriodicRefresh() } private fun loadPositions() { openPositionsLiveData?.removeObservers(viewLifecycleOwner) closedPositionsLiveData?.removeObservers(viewLifecycleOwner) + totalValueJob?.cancel() if (currentTab == PositionTab.OPEN) { binding.titleView.setSubTitle(getString(R.string.Open_Positions_Simple), "") @@ -170,14 +181,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions openPositionsLiveData = viewModel.getOpenPositionsPaged(walletId) openPositionsLiveData?.observe(viewLifecycleOwner, openPositionsObserver) - - lifecycleScope.launch { - val totalPositionValue = viewModel.getTotalOpenPositionValueFromDb(walletId) - val totalPnl = viewModel.getTotalUnrealizedPnlFromDb(walletId) - val percent = calculatePercent(totalPnl, totalPositionValue) - totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPositionValue)) - totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) - } + observeOpenTotals(walletId) } private fun loadClosedPositions() { @@ -193,13 +197,58 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions closedPositionsLiveData = viewModel.getClosedPositionsPaged(walletId) closedPositionsLiveData?.observe(viewLifecycleOwner, closedPositionsObserver) + observeClosedTotals(walletId) + } + + private fun observeOpenTotals(walletId: String) { + totalValueJob?.cancel() + totalValueJob = viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + combine( + viewModel.observeTotalOpenPositionValue(walletId), + viewModel.observeTotalUnrealizedPnl(walletId) + ) { totalPositionValue, totalPnl -> + totalPositionValue to totalPnl + }.collect { (totalPositionValue, totalPnl) -> + val percent = calculatePercent(totalPnl, totalPositionValue) + totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPositionValue)) + totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + } + } + } + } - lifecycleScope.launch { - val totalPnl = viewModel.getTotalRealizedPnlFromDb(walletId) - val totalEntryValue = viewModel.getTotalClosedEntryValueFromDb(walletId) - val percent = calculatePercent(totalPnl, totalEntryValue) - totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) - totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + private fun observeClosedTotals(walletId: String) { + totalValueJob?.cancel() + totalValueJob = viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + combine( + viewModel.observeTotalRealizedPnl(walletId), + viewModel.observeTotalClosedEntryValue(walletId) + ) { totalPnl, totalEntryValue -> + totalPnl to totalEntryValue + }.collect { (totalPnl, totalEntryValue) -> + val percent = calculatePercent(totalPnl, totalEntryValue) + totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) + totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + } + } + } + } + + private fun observePeriodicRefresh() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + val walletId = Session.getAccountId() ?: return@repeatOnLifecycle + while (isActive) { + viewModel.refreshPositions(walletId) + viewModel.refreshPositionHistory( + walletId = walletId, + limit = CLOSED_POSITION_REFRESH_LIMIT + ) + delay(POSITION_REFRESH_INTERVAL_MS) + } + } } } 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 c20cb610f5..516fc69aba 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 @@ -40,6 +40,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import one.mixin.android.R import one.mixin.android.Constants import one.mixin.android.api.response.perps.PerpsMarket @@ -54,6 +60,9 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal +private const val POSITION_REFRESH_INTERVAL_MS = 10_000L +private const val CLOSED_POSITION_PREVIEW_LIMIT = 10 + @Composable fun PerpetualContent( onShowTradingGuide: () -> Unit, @@ -74,24 +83,27 @@ fun PerpetualContent( val fiatSymbol = Fiats.getSymbol() val fiatRate = BigDecimal(Fiats.getRate()) val viewModel = hiltViewModel() + val lifecycleOwner = LocalLifecycleOwner.current var markets by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } - var openPositionsCount by remember { mutableStateOf(0) } - var openPositions by remember { mutableStateOf>(emptyList()) } - var totalPnl by remember { mutableStateOf(0.0) } - var closedPositions by remember { mutableStateOf>(emptyList()) } - var isLoadingHistory by remember { mutableStateOf(false) } + val openPositions by remember(walletId) { + viewModel.observeOpenPositions(walletId) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val totalPnl by remember(walletId) { + viewModel.observeTotalUnrealizedPnl(walletId) + }.collectAsStateWithLifecycle(initialValue = 0.0) + val closedPositions by remember(walletId) { + viewModel.observeClosedPositions(walletId, CLOSED_POSITION_PREVIEW_LIMIT) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val openPositionsCount = openPositions.size val openPositionsPreview = openPositions.take(3) val marketsPreview = markets.take(3) val closedPositionsPreview = closedPositions.take(3) val totalPnlFiatText = "${fiatSymbol}${BigDecimal.valueOf(totalPnl).multiply(fiatRate).priceFormat()}" LaunchedEffect(Unit) { - // Refresh positions from API - viewModel.refreshPositions(walletId) - viewModel.loadMarkets( onSuccess = { data -> markets = data @@ -102,29 +114,15 @@ fun PerpetualContent( isLoading = false } ) + } - if (walletId.isNotEmpty()) { - viewModel.getOpenPositions(walletId) { positions -> - openPositions = positions - openPositionsCount = positions.size - } - - viewModel.getTotalUnrealizedPnl(walletId) { pnl -> - totalPnl = pnl + LaunchedEffect(walletId, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + viewModel.refreshPositions(walletId) + viewModel.refreshPositionHistory(walletId, limit = CLOSED_POSITION_PREVIEW_LIMIT) + delay(POSITION_REFRESH_INTERVAL_MS) } - - isLoadingHistory = true - viewModel.loadPositionHistory( - walletId = walletId, - limit = 10, - onSuccess = { history -> - closedPositions = history - isLoadingHistory = false - }, - onError = { error -> - isLoadingHistory = false - } - ) } } @@ -364,19 +362,7 @@ fun PerpetualContent( } Spacer(modifier = Modifier.height(12.dp)) - if (isLoadingHistory) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - color = MixinAppTheme.colors.accent, - modifier = Modifier.size(32.dp) - ) - } - } else if (closedPositions.isEmpty()) { + if (closedPositions.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() @@ -475,22 +461,15 @@ private fun ViewAllAction(onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.view_all), - fontSize = 13.sp, + fontSize = 14.sp, color = MixinAppTheme.colors.accent, - fontWeight = FontWeight.Medium, - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - painter = painterResource(R.drawable.ic_arrow_right), - contentDescription = null, - tint = MixinAppTheme.colors.accent, - modifier = Modifier.size(14.dp) + fontWeight = FontWeight.W500, ) } } 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 cab7521717..98c70fb93d 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 @@ -7,6 +7,7 @@ import androidx.paging.LivePagedListBuilder import androidx.paging.PagedList import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.Constants @@ -47,6 +48,33 @@ class PerpetualViewModel @Inject constructor( jobManager.addJobInBackground(RefreshPerpsPositionsJob(walletId)) } + fun refreshPositionHistory(walletId: String, limit: Int = 100, offset: String? = null) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + routeService.getPerpsPositionHistory( + walletId = walletId, + limit = limit, + offset = offset + ) + } + + val data = response.data + if (response.isSuccess && data != null) { + val histories = data.map { it.copy(walletId = walletId) } + withContext(Dispatchers.IO) { + perpsPositionHistoryDao.insertAll(histories) + } + Timber.d("Perps position history refreshed: ${histories.size} items") + } else { + Timber.e("Failed to refresh position history: ${response.errorDescription}") + } + } catch (e: Exception) { + Timber.e(e, "Error refreshing position history") + } + } + } + fun loadMarkets( onSuccess: (List) -> Unit, onError: (String) -> Unit @@ -258,6 +286,14 @@ class PerpetualViewModel @Inject constructor( } } + fun observeOpenPositions(walletId: String): Flow> { + return perpsPositionDao.observeOpenPositions(walletId) + } + + fun observeClosedPositions(walletId: String, limit: Int): Flow> { + return perpsPositionHistoryDao.observeHistories(walletId, limit) + } + suspend fun getOpenPositionsFromDb(walletId: String): List { return withContext(Dispatchers.IO) { try { @@ -294,6 +330,14 @@ class PerpetualViewModel @Inject constructor( } } + fun observeTotalUnrealizedPnl(walletId: String): Flow { + return perpsPositionDao.observeTotalUnrealizedPnl(walletId) + } + + fun observeTotalOpenPositionValue(walletId: String): Flow { + return perpsPositionDao.observeTotalOpenPositionValue(walletId) + } + suspend fun getTotalUnrealizedPnlFromDb(walletId: String): Double { return withContext(Dispatchers.IO) { try { @@ -327,6 +371,10 @@ class PerpetualViewModel @Inject constructor( } } + fun observeTotalRealizedPnl(walletId: String): Flow { + return perpsPositionHistoryDao.observeTotalRealizedPnl(walletId) + } + suspend fun getTotalClosedEntryValueFromDb(walletId: String): Double { return withContext(Dispatchers.IO) { try { @@ -338,6 +386,10 @@ class PerpetualViewModel @Inject constructor( } } + fun observeTotalClosedEntryValue(walletId: String): Flow { + return perpsPositionHistoryDao.observeTotalClosedEntryValue(walletId) + } + fun getOpenPositionsPaged(walletId: String, initialLoadKey: Int? = 0): LiveData> { val config = PagedList.Config.Builder() .setPrefetchDistance(Constants.PAGE_SIZE * 2) From f24e9fed0a526bb007ad6b72fd12497aa1c00c84 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 15:43:27 +0800 Subject: [PATCH 032/105] Support perps url --- .../android/ui/common/BottomSheetViewModel.kt | 5 ++++ .../link/LinkBottomSheetDialogFragment.kt | 27 ++++++++++++++++++- .../home/web3/trade/perps/OpenPositionItem.kt | 5 ++-- .../home/web3/trade/perps/PerpetualContent.kt | 17 +++++++++--- .../trade/perps/PerpsPositionShareActivity.kt | 15 +++++++---- 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt index 6ee7d9ecba..84ae5ba48a 100644 --- a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt @@ -2035,4 +2035,9 @@ class BottomSheetViewModel suspend fun fetchSessionsSuspend(ids: List) = userRepository.fetchSessionsSuspend(ids) suspend fun getReferralCodeInfo(code: String) = userRepository.getReferralCodeInfo(code) + + suspend fun getPerpsMarket(marketId: String): one.mixin.android.api.response.perps.PerpsMarket? = withContext(Dispatchers.IO) { + val response = web3Repository.routeService.getPerpsMarket(marketId) + response.data + } } 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 0a84f194bc..55a4194271 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 @@ -86,6 +86,7 @@ import one.mixin.android.ui.home.inscription.InscriptionActivity import one.mixin.android.ui.home.web3.GasCheckBottomSheetDialogFragment import one.mixin.android.ui.home.web3.trade.SwapActivity import one.mixin.android.ui.home.web3.trade.TradeFragment.Companion.PREF_TRADE_SELECTED_TAB_PREFIX +import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity import one.mixin.android.ui.oldwallet.BottomSheetViewModel import one.mixin.android.ui.oldwallet.MultisigsBottomSheetDialogFragment import one.mixin.android.ui.oldwallet.NftBottomSheetDialogFragment @@ -1076,10 +1077,34 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { } private suspend fun handleTradeScheme(uri: Uri) { + val type = uri.getQueryParameter("type") + + if (type.equals("perps", true)) { + val productId = uri.getQueryParameter("product") + if (productId.isNullOrBlank() || !productId.isUUID()) { + showError(R.string.Invalid_payment_link) + return + } + + val market = linkViewModel.getPerpsMarket(productId) + if (market == null) { + showError(R.string.Data_error) + return + } + + PerpsActivity.showDetail( + requireContext(), + market.marketId, + market.symbol, + market.displaySymbol + ) + dismiss() + return + } + val input = uri.getQueryParameter("input") val output = uri.getQueryParameter("output") val amount = uri.getQueryParameter("amount") - val type = uri.getQueryParameter("type") if (output != null && output.isUUID()) { checkToken(output) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt index 9cd62b527d..1721f8abd4 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt @@ -41,7 +41,8 @@ fun OpenPositionItem( val context = LocalContext.current val quoteColorPref = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) - val pnl = position.unrealizedPnl?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val positionValue = (position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO).abs().multiply(markPrice) val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() @@ -113,7 +114,7 @@ fun OpenPositionItem( Column(horizontalAlignment = Alignment.End) { Text( - text = "${fiatSymbol}${pnl.abs().multiply(fiatRate).priceFormat()}", + text = "${fiatSymbol}${positionValue.multiply(fiatRate).priceFormat()}", fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary ) 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 516fc69aba..e5cd324f3c 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 @@ -101,7 +101,18 @@ fun PerpetualContent( val openPositionsPreview = openPositions.take(3) val marketsPreview = markets.take(3) val closedPositionsPreview = closedPositions.take(3) - val totalPnlFiatText = "${fiatSymbol}${BigDecimal.valueOf(totalPnl).multiply(fiatRate).priceFormat()}" + val totalPositionValue = openPositions.fold(BigDecimal.ZERO) { total, position -> + val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO + val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO + total + quantity.abs().multiply(markPrice) + } + val totalPositionValueFiatText = "${fiatSymbol}${totalPositionValue.multiply(fiatRate).priceFormat()}" + val totalPnlFiatText = "${if (totalPnl >= 0) "+" else "-"}$fiatSymbol${BigDecimal.valueOf(totalPnl).abs().multiply(fiatRate).priceFormat()}" + val totalPnlPercent = if (totalPositionValue == BigDecimal.ZERO) { + 0.0 + } else { + totalPnl / totalPositionValue.toDouble() * 100 + } LaunchedEffect(Unit) { viewModel.loadMarkets( @@ -150,7 +161,7 @@ fun PerpetualContent( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = totalPnlFiatText, + text = totalPositionValueFiatText, fontSize = 18.sp, fontWeight = FontWeight.W600, color = MixinAppTheme.colors.textPrimary, @@ -164,7 +175,7 @@ fun PerpetualContent( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = String.format("(%s%.2f%%)", if (totalPnl >= 0) "+" else "", totalPnl), + text = String.format("(%s%.2f%%)", if (totalPnlPercent >= 0) "+" else "", kotlin.math.abs(totalPnlPercent)), fontSize = 14.sp, color = if (totalPnl >= 0) risingColor else fallingColor, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt index 301e76df38..e53686f9af 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt @@ -52,7 +52,6 @@ class PerpsPositionShareActivity : BaseActivity() { companion object { private const val ARGS_POSITION = "args_position" private const val ARGS_POSITION_HISTORY = "args_position_history" - private const val SHARE_INSTALL_URL = "https://mixin.one/mm" fun show(context: Context, position: PerpsPositionItem) { refreshScreenshot(context, 0x33000000) @@ -84,11 +83,17 @@ class PerpsPositionShareActivity : BaseActivity() { } private val shareLink: String by lazy { - val identity = Session.getAccount()?.identityNumber - if (identity.isNullOrEmpty()) { - SHARE_INSTALL_URL + val identity = Session.getAccount()?.identityNumber ?: "" + val productId = position?.productId ?: positionHistory?.productId + if (productId != null) { + val baseUrl = "https://mixin.one/trade?type=perps&product=$productId" + if (identity.isNotEmpty()) { + "$baseUrl&referral=$identity" + } else { + baseUrl + } } else { - "$SHARE_INSTALL_URL?ref=$identity" + throw IllegalArgumentException("lost data") } } From 6daa36cae5214d5b137a9b9c6b4b7950b5d44689 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 16:12:53 +0800 Subject: [PATCH 033/105] Update tip icon --- .../ui/home/web3/trade/HelpBottomSheet.kt | 15 +- .../ui/home/web3/trade/TradeFragment.kt | 3 +- .../android/ui/home/web3/trade/TradePage.kt | 1 + .../home/web3/trade/perps/OpenPositionPage.kt | 58 ++++++-- .../trade/perps/PerpetualGuideFragment.kt | 41 +++--- .../PerpsConfirmBottomSheetDialogFragment.kt | 36 +++-- .../web3/trade/perps/PerpsMarketDetailPage.kt | 130 +++++++++--------- .../trade/perps/PerpsPositionShareActivity.kt | 6 +- 8 files changed, 168 insertions(+), 122 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt index f36e03782e..228091a9d2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt @@ -26,6 +26,7 @@ import one.mixin.android.compose.theme.MixinAppTheme @Composable fun HelpBottomSheetContent( + hideGuide: Boolean = false, onContactSupport: () -> Unit, onTradingGuide: () -> Unit, onDismiss: () -> Unit, @@ -41,14 +42,12 @@ fun HelpBottomSheetContent( onClick = onContactSupport ) - Spacer(modifier = Modifier.height(1.dp)) - - HelpOption( - title = stringResource(R.string.Trading_Guide), - onClick = onTradingGuide - ) - - Spacer(modifier = Modifier.height(8.dp)) + if (!hideGuide){ + HelpOption( + title = stringResource(R.string.Trading_Guide), + onClick = onTradingGuide + ) + } HelpOption( title = stringResource(R.string.Cancel), 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 cae791668d..0ed40b1250 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 @@ -334,7 +334,8 @@ class TradeFragment : BaseFragment() { }, onShowTradingGuide = { this@apply.hideKeyboard() - navTo(PerpetualGuideFragment.newInstance(), PerpetualGuideFragment.TAG) + PerpetualGuideFragment.newInstance() + .show(parentFragmentManager, PerpetualGuideFragment.TAG) }, pop = { navigateUp(navController) 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 43ad12b61c..ae543c48ee 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 @@ -215,6 +215,7 @@ fun TradePage( sheetBackgroundColor = MixinAppTheme.colors.background, sheetContent = { HelpBottomSheetContent( + hideGuide = pagerState.currentPage != 2, onContactSupport = { coroutineScope.launch { bottomSheetState.hide() 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 5c4ea62f6a..daae217c95 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 @@ -125,8 +125,6 @@ fun OpenPositionPage( .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) - Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -300,7 +298,9 @@ fun OpenPositionPage( contentAlignment = Alignment.Center ) { Text( - modifier = Modifier.padding(horizontal = 10.dp).widthIn(min = 20.dp), + modifier = Modifier + .padding(horizontal = 10.dp) + .widthIn(min = 20.dp), textAlign = TextAlign.Center, text = displayText, fontSize = 12.sp, @@ -338,12 +338,26 @@ fun OpenPositionPage( .padding(horizontal = 4.dp), ) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = stringResource(R.string.Order_Value), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) + Row (verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Order_Value), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_tip), + contentDescription = null, + modifier = Modifier + .size(12.dp) + .clickable { + val activity = context as? FragmentActivity ?: return@clickable + PerpetualGuideFragment.newInstance() + .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } Text( text = "${calculateOrderValue(usdtAmount, leverage, market?.markPrice ?: "0")} ${market?.tokenSymbol}", fontSize = 14.sp, @@ -352,12 +366,26 @@ fun OpenPositionPage( } Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = stringResource(R.string.Liquidation_Price), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) + Row (verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Liquidation_Price), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_tip), + contentDescription = null, + modifier = Modifier + .size(12.dp) + .clickable { + val activity = context as? FragmentActivity ?: return@clickable + PerpetualGuideFragment.newInstance() + .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } Text( text = calculateLiquidationPrice( market?.markPrice ?: "0", diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt index aebf46ae37..ed8271e3e4 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt @@ -1,15 +1,15 @@ package one.mixin.android.ui.home.web3.trade.perps -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.fragment.app.Fragment +import androidx.compose.runtime.Composable import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.screenHeight +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment + @AndroidEntryPoint -class PerpetualGuideFragment : Fragment() { +class PerpetualGuideFragment : MixinComposeBottomSheetDialogFragment() { companion object { const val TAG = "PerpetualGuideFragment" @@ -17,22 +17,21 @@ class PerpetualGuideFragment : Fragment() { fun newInstance() = PerpetualGuideFragment() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return ComposeView(requireContext()).apply { - setContent { - MixinAppTheme { - PerpetualGuidePage( - pop = { - requireActivity().onBackPressedDispatcher.onBackPressed() - } - ) + @Composable + override fun ComposeContent() { + MixinAppTheme { + PerpetualGuidePage( + pop = { + dismiss() } - } + ) } } -} + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + override fun showError(error: String) { + } +} 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 2ad4b2fcd9..480c639d01 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 @@ -8,6 +8,7 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup 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 @@ -169,7 +170,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm try { val price = entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO Timber.d("LiquidationPrice - entryPrice: $entryPrice, leverage: $leverage, isLong: $isLong, price: $price") - + if (price == BigDecimal.ZERO) { "0" } else { @@ -390,7 +391,8 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm PerpsInfoItem( title = stringResource(R.string.Estimated_Liquidation_Price).uppercase(), value = liquidationPrice, - subValue = lossSubValue + subValue = lossSubValue, + info = true ) Box(modifier = Modifier.height(20.dp)) @@ -462,18 +464,36 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm title: String, value: String, subValue: String? = null, + info: Boolean = false, ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp), ) { - Text( - text = title, - color = MixinAppTheme.colors.textRemarks, - fontSize = 14.sp, - maxLines = 1, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + maxLines = 1, + ) + if (info) { + Spacer(modifier = Modifier.width(4.dp)) + + Icon( + painter = painterResource(id = R.drawable.ic_tip), + contentDescription = null, + modifier = Modifier + .size(12.dp) + .clickable { + PerpetualGuideFragment.newInstance() + .show(parentFragmentManager, PerpetualGuideFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } + } Box(modifier = Modifier.height(4.dp)) Text( text = value, 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 83d9cfc5cf..92c6c62a6e 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 @@ -22,6 +22,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -168,24 +169,7 @@ fun PerpsMarketDetailPage( Spacer(modifier = Modifier.height(16.dp)) if (currentPosition != null) { - OpenPositionCard( - position = currentPosition!!, - onClick = { - val activity = context as? FragmentActivity - val position = currentPosition - if (activity != null && position != null) { - activity.supportFragmentManager - .beginTransaction() - .add( - android.R.id.content, - PositionDetailFragment.newInstance(position), - PositionDetailFragment.TAG - ) - .addToBackStack(null) - .commit() - } - } - ) + OpenPositionCard(position = currentPosition!!) Spacer(modifier = Modifier.height(16.dp)) } @@ -194,15 +178,8 @@ fun PerpsMarketDetailPage( market = market!!, onLearnClick = { val activity = context as? FragmentActivity ?: return@MarketInfoCard - activity.supportFragmentManager - .beginTransaction() - .add( - android.R.id.content, - PerpetualGuideFragment.newInstance(), - PerpetualGuideFragment.TAG - ) - .addToBackStack(null) - .commit() + PerpetualGuideFragment.newInstance() + .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) } ) } @@ -431,24 +408,6 @@ private fun MarketInfoCard( color = MixinAppTheme.colors.textPrimary ) - Spacer(modifier = Modifier.height(12.dp)) - - - Column { - Text( - text = stringResource(R.string.Open_Interest), - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.N_A), - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary - ) - } - Spacer(modifier = Modifier.height(12.dp)) Column { Text( @@ -608,7 +567,6 @@ private fun MarketDetailCard( @Composable private fun OpenPositionCard( position: PerpsPositionItem, - onClick: () -> Unit, ) { val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences @@ -637,7 +595,6 @@ private fun OpenPositionCard( .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) - .clickable { onClick() } .padding(16.dp) ) { Row( @@ -701,12 +658,26 @@ private fun OpenPositionCard( horizontalArrangement = Arrangement.SpaceBetween ) { Column { - Text( - text = stringResource(R.string.Order_Value), - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Order_Value), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_tip), + contentDescription = null, + modifier = Modifier + .size(12.dp) + .clickable { + val activity = context as? FragmentActivity ?: return@clickable + PerpetualGuideFragment.newInstance() + .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } Text( text = "${quantity.stripTrailingZeros().toPlainString()} ${position.tokenSymbol}", fontSize = 14.sp, @@ -715,12 +686,27 @@ private fun OpenPositionCard( } Column(horizontalAlignment = Alignment.End) { - Text( - text = stringResource(R.string.Amount), - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Amount), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_tip), + contentDescription = null, + modifier = Modifier + .size(12.dp) + .clickable { + val activity = context as? FragmentActivity ?: return@clickable + PerpetualGuideFragment.newInstance() + .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } Text( text = "${fiatSymbol}${orderValue.priceFormat()}", fontSize = 14.sp, @@ -750,12 +736,26 @@ private fun OpenPositionCard( } Column(horizontalAlignment = Alignment.End) { - Text( - text = stringResource(R.string.Liquidation_Price), - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Liquidation_Price), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_tip), + contentDescription = null, + modifier = Modifier + .size(12.dp) + .clickable { + val activity = context as? FragmentActivity ?: return@clickable + PerpetualGuideFragment.newInstance() + .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } Text( text = "${fiatSymbol}${liquidationPrice.multiply(fiatRate).priceFormat()}", fontSize = 14.sp, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt index e53686f9af..a699f4f26a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt @@ -13,6 +13,7 @@ import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams import androidx.core.content.FileProvider +import androidx.core.view.WindowCompat import androidx.core.view.drawToBitmap import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope @@ -115,10 +116,7 @@ class PerpsPositionShareActivity : BaseActivity() { }) } - window.decorView.systemUiVisibility = ( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - ) + WindowCompat.setDecorFitsSystemWindows(window, false) window.statusBarColor = Color.TRANSPARENT binding.content.updateLayoutParams { From dc735d2dc021cdbd7c749adb1f8cd110067548f9 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 16:51:36 +0800 Subject: [PATCH 034/105] Update deposit --- .../home/web3/trade/perps/OpenPositionPage.kt | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) 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 daae217c95..cc0a6f9d9b 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 @@ -58,6 +58,9 @@ import one.mixin.android.extension.priceFormat import one.mixin.android.extension.putInt import one.mixin.android.session.Session import one.mixin.android.ui.home.web3.trade.InputContent +import one.mixin.android.ui.home.web3.trade.SwapActivity +import one.mixin.android.ui.wallet.AddFeeBottomSheetDialogFragment +import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import one.mixin.android.vo.safe.TokenItem @@ -112,6 +115,11 @@ fun OpenPositionPage( val directionColor = if (isLong) risingColor else fallingColor val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() + val inputAmount = usdtAmount.toBigDecimalOrNull() + val tokenBalance = selectedToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val hasInputAmount = inputAmount != null && inputAmount > BigDecimal.ZERO + val insufficientBalance = hasInputAmount && inputAmount > tokenBalance + val canReview = hasInputAmount && !insufficientBalance MixinAppTheme { PageScaffold( @@ -207,6 +215,37 @@ fun OpenPositionPage( usdtAmount = selectedToken?.balance ?: "0" } ) + if (insufficientBalance) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.Add), + style = TextStyle( + fontSize = 12.sp, + color = MixinAppTheme.colors.accent, + ), + modifier = Modifier.clickable { + val activity = context as? FragmentActivity ?: return@clickable + val token = selectedToken ?: return@clickable + AddFeeBottomSheetDialogFragment.newInstance(token) + .apply { + onAction = { type, addToken -> + if (type == AddFeeBottomSheetDialogFragment.ActionType.SWAP) { + SwapActivity.show( + context = activity, + input = Constants.AssetId.USDT_ASSET_ETH_ID, + output = addToken.assetId, + amount = null, + referral = null + ) + } else if (type == AddFeeBottomSheetDialogFragment.ActionType.DEPOSIT) { + WalletActivity.showDeposit(activity, addToken) + } + } + } + .show(activity.supportFragmentManager, AddFeeBottomSheetDialogFragment.TAG) + } + ) + } } } Spacer(modifier = Modifier.height(2.dp)) @@ -413,6 +452,7 @@ fun OpenPositionPage( val amount = usdtAmount.toBigDecimalOrNull() ?: return@Button if (amount <= BigDecimal.ZERO) return@Button + if (amount > (token.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO)) return@Button val m = market ?: return@Button val walletId = Session.getAccountId() ?: "" // Privacy Wallet @@ -453,9 +493,9 @@ fun OpenPositionPage( } ) }, - enabled = usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO, + enabled = canReview, colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { + backgroundColor = if (canReview) { MixinAppTheme.colors.accent } else { MixinAppTheme.colors.backgroundGrayLight @@ -470,10 +510,14 @@ fun OpenPositionPage( ) ) { Text( - text = stringResource(R.string.Review), + text = if (insufficientBalance) { + "${selectedToken?.symbol ?: ""} ${stringResource(R.string.insufficient_balance)}" + } else { + stringResource(R.string.Review) + }, fontSize = 16.sp, fontWeight = FontWeight.Bold, - color = if (usdtAmount.isNotBlank() && (usdtAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO) > BigDecimal.ZERO) { + color = if (canReview) { Color.White } else { MixinAppTheme.colors.textAssist From 7f3c770f7e0da4b94247fa6251b900bc8b64313e Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 17:21:04 +0800 Subject: [PATCH 035/105] Update --- .../home/web3/trade/perps/OpenPositionPage.kt | 43 ++++++++++++++----- .../home/web3/trade/perps/PerpetualContent.kt | 2 +- .../ui/home/web3/trade/perps/PerpsActivity.kt | 4 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 16 ++++++- .../trade/perps/PositionDetailFragment.kt | 7 +++ .../web3/trade/perps/PositionDetailPage.kt | 27 +++++++++++- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 86 insertions(+), 15 deletions(-) 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 cc0a6f9d9b..8c0d877f5c 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 @@ -24,6 +24,7 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -54,6 +55,7 @@ import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.numberFormat8 +import one.mixin.android.extension.openUrl import one.mixin.android.extension.priceFormat import one.mixin.android.extension.putInt import one.mixin.android.session.Session @@ -78,13 +80,14 @@ fun OpenPositionPage( displaySymbol: String, isLong: Boolean, onBack: () -> Unit, + selectedToken: TokenItem? = null, onTokenSelect: () -> Unit = {}, ) { val context = LocalContext.current val viewModel = hiltViewModel() var market by remember { mutableStateOf(null) } - var selectedToken by remember { mutableStateOf(null) } + var currentToken by remember { mutableStateOf(selectedToken) } var availableTokens by remember { mutableStateOf>(emptyList()) } var usdtAmount by remember { mutableStateOf("") } @@ -102,7 +105,15 @@ fun OpenPositionPage( viewModel.loadUsdTokens { tokens -> availableTokens = tokens - selectedToken = tokens.firstOrNull() + currentToken = selectedToken?.let { target -> + tokens.firstOrNull { it.assetId == target.assetId } ?: target + } ?: tokens.firstOrNull() + } + } + + LaunchedEffect(selectedToken?.assetId, availableTokens) { + selectedToken?.let { target -> + currentToken = availableTokens.firstOrNull { it.assetId == target.assetId } ?: target } } @@ -116,7 +127,7 @@ fun OpenPositionPage( val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() val inputAmount = usdtAmount.toBigDecimalOrNull() - val tokenBalance = selectedToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val tokenBalance = currentToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO val hasInputAmount = inputAmount != null && inputAmount > BigDecimal.ZERO val insufficientBalance = hasInputAmount && inputAmount > tokenBalance val canReview = hasInputAmount && !insufficientBalance @@ -124,8 +135,20 @@ fun OpenPositionPage( MixinAppTheme { PageScaffold( title = stringResource(R.string.Open_Position), + subtitleText = stringResource(R.string.Perpetual), verticalScrollable = false, - pop = onBack + pop = onBack, + actions = { + IconButton(onClick = { + context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + }) { + Icon( + painter = painterResource(id = R.drawable.ic_support), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) + } + } ) { Column( modifier = Modifier @@ -183,7 +206,7 @@ fun OpenPositionPage( Spacer(modifier = Modifier.height(8.dp)) InputContent( - token = selectedToken?.toSwapToken(), + token = currentToken?.toSwapToken(), text = usdtAmount, selectClick = { onTokenSelect() @@ -205,14 +228,14 @@ fun OpenPositionPage( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = selectedToken?.balance?.numberFormat8() ?: "0", + text = currentToken?.balance?.numberFormat8() ?: "0", style = TextStyle( fontSize = 12.sp, color = MixinAppTheme.colors.textAssist, textAlign = TextAlign.Start, ), modifier = Modifier.clickable { - usdtAmount = selectedToken?.balance ?: "0" + usdtAmount = currentToken?.balance ?: "0" } ) if (insufficientBalance) { @@ -225,7 +248,7 @@ fun OpenPositionPage( ), modifier = Modifier.clickable { val activity = context as? FragmentActivity ?: return@clickable - val token = selectedToken ?: return@clickable + val token = currentToken ?: return@clickable AddFeeBottomSheetDialogFragment.newInstance(token) .apply { onAction = { type, addToken -> @@ -448,7 +471,7 @@ fun OpenPositionPage( .fillMaxWidth() .height(48.dp), onClick = { - val token = selectedToken ?: return@Button + val token = currentToken ?: return@Button val amount = usdtAmount.toBigDecimalOrNull() ?: return@Button if (amount <= BigDecimal.ZERO) return@Button @@ -511,7 +534,7 @@ fun OpenPositionPage( ) { Text( text = if (insufficientBalance) { - "${selectedToken?.symbol ?: ""} ${stringResource(R.string.insufficient_balance)}" + "${currentToken?.symbol ?: ""} ${stringResource(R.string.insufficient_balance)}" } else { stringResource(R.string.Review) }, 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 e5cd324f3c..9ead886947 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 @@ -283,7 +283,7 @@ fun PerpetualContent( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(R.string.Markets), + text = stringResource(R.string.Perpetual_Markets), fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) 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 fd0eee6daa..a8eb8fa386 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 @@ -78,6 +78,7 @@ class PerpsActivity : BaseActivity() { displaySymbol = displaySymbol, isLong = isLong, onBack = { finish() }, + selectedToken = selectedToken, onTokenSelect = { showTokenSelection() } ) } @@ -97,7 +98,8 @@ class PerpsActivity : BaseActivity() { private fun showTokenSelection() { TokenListBottomSheetDialogFragment.newInstance( - fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP + fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP, + currentAssetId = selectedToken?.assetId ).setOnAssetClick { token -> selectedToken = token }.show(supportFragmentManager, TokenListBottomSheetDialogFragment.TAG) 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 92c6c62a6e..bf24d5767a 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 @@ -23,6 +23,7 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -56,6 +57,7 @@ import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.marketPriceFormat +import one.mixin.android.extension.openUrl import one.mixin.android.extension.priceFormat import one.mixin.android.session.Session import one.mixin.android.ui.home.web3.trade.CandleChart @@ -119,8 +121,20 @@ fun PerpsMarketDetailPage( PageScaffold( title = displaySymbol, + subtitleText = stringResource(R.string.Perpetual), verticalScrollable = false, - pop = onBack + pop = onBack, + actions = { + IconButton(onClick = { + context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + }) { + Icon( + painter = painterResource(id = R.drawable.ic_support), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) + } + } ) { Box(modifier = Modifier.fillMaxSize()) { Column( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt index 7c7cdde898..91827eb4d5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -12,6 +12,7 @@ import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.openUrl import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.api.response.perps.toPosition @@ -72,6 +73,9 @@ class PositionDetailFragment : BaseFragment() { }, onShare = { PerpsPositionShareActivity.show(requireContext(), position) + }, + onSupport = { + context?.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) } ) } else if (positionHistory != null) { @@ -86,6 +90,9 @@ class PositionDetailFragment : BaseFragment() { }, onShare = { PerpsPositionShareActivity.show(requireContext(), positionHistory) + }, + onSupport = { + context?.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) } ) } 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 c5f3acdbb5..bec3bf6e0b 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 @@ -18,12 +18,15 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape 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.text.style.TextAlign @@ -49,6 +52,7 @@ fun PositionDetailPage( pop: () -> Unit, onClose: (() -> Unit)? = null, onShare: (() -> Unit)? = null, + onSupport: (() -> Unit)? = null, ) { val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) @@ -94,7 +98,16 @@ fun PositionDetailPage( PageScaffold( title = title, verticalScrollable = false, - pop = pop + pop = pop, + actions = { + IconButton(onClick = { onSupport?.invoke() }) { + Icon( + painter = painterResource(id = R.drawable.ic_support), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) + } + } ) { Column( modifier = Modifier @@ -323,6 +336,7 @@ fun PositionDetailPage( pop: () -> Unit, onTradeAgain: (() -> Unit)? = null, onShare: (() -> Unit)? = null, + onSupport: (() -> Unit)? = null, ) { val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) @@ -368,7 +382,16 @@ fun PositionDetailPage( PageScaffold( title = title, verticalScrollable = false, - pop = pop + pop = pop, + actions = { + IconButton(onClick = { onSupport?.invoke() }) { + Icon( + painter = painterResource(id = R.drawable.ic_support), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) + } + } ) { Column( modifier = Modifier diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1c83030da5..efc710f6b5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2330,4 +2330,5 @@ 未检测到sim卡。 选择一个国家或地区 匿名号码 + 市场 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 160c5eb0ec..8820f1c1b0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2393,4 +2393,5 @@ No sim card detected. Choose a Country or Region Anonymous Number + Markets From b984f31cd994bd2581d36f32979344d731de0a87 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 4 Mar 2026 19:05:18 +0800 Subject: [PATCH 036/105] Update refresh --- .../android/db/perps/PerpsPositionDao.kt | 14 +++++++ .../android/job/RefreshPerpsPositionsJob.kt | 7 +++- .../web3/trade/perps/PerpetualViewModel.kt | 38 +++++++++++++++++ .../PerpsConfirmBottomSheetDialogFragment.kt | 38 ++++++++++------- .../trade/perps/PositionDetailFragment.kt | 41 +++++++++++++++++-- 5 files changed, 119 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index 2f86f54519..a16f82e9cf 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -55,12 +55,26 @@ interface PerpsPositionDao : BaseDao { """) suspend fun getPosition(positionId: String): PerpsPositionItem? + @Query(""" + SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol + FROM positions p + LEFT JOIN markets m ON m.market_id = p.product_id + WHERE p.position_id = :positionId + """) + fun observePosition(positionId: String): Flow + @Query("UPDATE positions SET state = :status, updated_at = :updatedAt WHERE position_id = :positionId") suspend fun updateStatus(positionId: String, status: String, updatedAt: String) @Query("DELETE FROM positions WHERE wallet_id = :walletId") suspend fun deleteByWallet(walletId: String) + @Query("DELETE FROM positions WHERE wallet_id = :walletId AND state = 'open'") + suspend fun deleteOpenByWallet(walletId: String) + + @Query("DELETE FROM positions WHERE wallet_id = :walletId AND state = 'open' AND position_id NOT IN (:positionIds)") + suspend fun deleteOpenByWalletAndNotIn(walletId: String, positionIds: List) + @Query("SELECT SUM(CAST(unrealized_pnl AS REAL)) FROM positions WHERE wallet_id = :walletId AND state = 'open'") suspend fun getTotalUnrealizedPnl(walletId: String): Double? diff --git a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt index c59a947635..fac5a06c27 100644 --- a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt +++ b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt @@ -46,8 +46,11 @@ class RefreshPerpsPositionsJob( if (response.isSuccess && response.data != null) { val positions = response.data!!.map { it.copy(walletId = walletId) } Timber.d("RefreshPerpsPositionsJob: Fetched ${positions.size} positions for wallet $walletId") - - if (positions.isNotEmpty()) { + + if (positions.isEmpty()) { + positionDao.deleteOpenByWallet(walletId) + } else { + positionDao.deleteOpenByWalletAndNotIn(walletId, positions.map { it.positionId }) positionDao.insertAll(positions) } } else { 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 98c70fb93d..d63f2f53ae 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 @@ -26,6 +26,7 @@ import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.db.perps.PerpsPositionHistoryDao import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshPerpsPositionsJob +import one.mixin.android.util.ErrorHandler import one.mixin.android.vo.safe.TokenItem import timber.log.Timber import java.math.BigDecimal @@ -290,6 +291,43 @@ class PerpetualViewModel @Inject constructor( return perpsPositionDao.observeOpenPositions(walletId) } + fun observePosition(positionId: String): Flow { + return perpsPositionDao.observePosition(positionId) + } + + fun refreshSinglePosition(positionId: String, walletId: String? = null) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + routeService.getPerpsPosition(positionId) + } + + if (response.isSuccess) { + val remotePosition = response.data + withContext(Dispatchers.IO) { + if (remotePosition == null || !remotePosition.state.equals("open", ignoreCase = true)) { + perpsPositionDao.deleteById(positionId) + } else { + perpsPositionDao.insert( + remotePosition.copy( + walletId = walletId ?: remotePosition.walletId + ) + ) + } + } + } else if (response.errorCode == ErrorHandler.NOT_FOUND) { + withContext(Dispatchers.IO) { + perpsPositionDao.deleteById(positionId) + } + } else { + Timber.e("Failed to refresh position detail: ${response.errorDescription}") + } + } catch (e: Exception) { + Timber.e(e, "Error refreshing position detail: ${e.message}") + } + } + } + fun observeClosedPositions(walletId: String, limit: Int): Flow> { return perpsPositionHistoryDao.observeHistories(walletId, limit) } 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 480c639d01..f3e48cd232 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 @@ -62,6 +62,7 @@ import one.mixin.android.extension.composeDp import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.priceFormat import one.mixin.android.extension.putLong import one.mixin.android.extension.screenHeight import one.mixin.android.extension.updatePinCheck @@ -79,9 +80,11 @@ import one.mixin.android.ui.wallet.ItemUserContent import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager import one.mixin.android.vo.User +import one.mixin.android.vo.Fiats import one.mixin.android.vo.toUser import timber.log.Timber import java.math.BigDecimal +import java.math.RoundingMode import java.util.UUID @AndroidEntryPoint @@ -163,30 +166,37 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm private val leverage by lazy { requireArguments().getInt(ARGS_LEVERAGE) } private val entryPrice by lazy { requireNotNull(requireArguments().getString(ARGS_ENTRY_PRICE)) } private val tokenSymbol by lazy { requireNotNull(requireArguments().getString(ARGS_TOKEN_SYMBOL)) } + private val fiatRate by lazy { BigDecimal(Fiats.getRate()) } + private val fiatSymbol by lazy { Fiats.getSymbol() } private val payUrl by lazy { requireArguments().getString(ARGS_PAY_URL) } + private val entryFiatPrice by lazy { + val price = entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + "${fiatSymbol}${price.multiply(fiatRate).priceFormat()}" + } private val liquidationPrice by lazy { try { - val price = entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - Timber.d("LiquidationPrice - entryPrice: $entryPrice, leverage: $leverage, isLong: $isLong, price: $price") - - if (price == BigDecimal.ZERO) { - "0" + if (leverage <= 0) { + "${fiatSymbol}0" } else { - val liquidationPercent = BigDecimal(100.0 / leverage) - val liquidation = if (isLong) { - price * (BigDecimal.ONE - liquidationPercent / BigDecimal(100)) + val price = entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (price == BigDecimal.ZERO) { + "${fiatSymbol}0" } else { - price * (BigDecimal.ONE + liquidationPercent / BigDecimal(100)) + val liquidationPercent = BigDecimal(100.0 / leverage) + val liquidationRatio = liquidationPercent.divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + val liquidation = if (isLong) { + price * (BigDecimal.ONE - liquidationRatio) + } else { + price * (BigDecimal.ONE + liquidationRatio) + } + "${fiatSymbol}${liquidation.multiply(fiatRate).priceFormat()}" } - val result = String.format("%.2f", liquidation) - Timber.d("LiquidationPrice - liquidationPercent: $liquidationPercent, liquidation: $liquidation, result: $result") - result } } catch (e: Exception) { Timber.e(e, "Failed to calculate liquidation price") - "0" + "${fiatSymbol}0" } } @@ -358,7 +368,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm PerpsInfoItem( title = stringResource(R.string.Entry_Price).uppercase(), - value = "$$entryPrice" + value = entryFiatPrice ) Box(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt index 91827eb4d5..3fac27f669 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -5,8 +5,16 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import one.mixin.android.Constants import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences @@ -24,6 +32,7 @@ class PositionDetailFragment : BaseFragment() { const val TAG = "PositionDetailFragment" private const val ARGS_POSITION = "args_position" private const val ARGS_POSITION_HISTORY = "args_position_history" + private const val POSITION_REFRESH_INTERVAL_MS = 10_000L fun newInstance(position: PerpsPositionItem): PositionDetailFragment { return PositionDetailFragment().apply { @@ -62,17 +71,43 @@ class PositionDetailFragment : BaseFragment() { darkTheme = context.isNightMode(), ) { if (position != null) { + val lifecycleOwner = LocalLifecycleOwner.current + val positionFlow = remember(position.positionId) { + viewModel.observePosition(position.positionId) + } + val positionState = positionFlow + .collectAsStateWithLifecycle(initialValue = position) + + LaunchedEffect(position.positionId, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + viewModel.refreshSinglePosition( + positionId = position.positionId, + walletId = position.walletId, + ) + delay(POSITION_REFRESH_INTERVAL_MS) + } + } + } + + LaunchedEffect(positionState.value) { + if (positionState.value == null) { + activity?.onBackPressedDispatcher?.onBackPressed() + } + } + + val currentPosition = positionState.value ?: position PositionDetailPage( - position = position, + position = currentPosition, quoteColorReversed = quoteColorReversed, pop = { activity?.onBackPressedDispatcher?.onBackPressed() }, onClose = { - showCloseDialog(position) + showCloseDialog(currentPosition) }, onShare = { - PerpsPositionShareActivity.show(requireContext(), position) + PerpsPositionShareActivity.show(requireContext(), currentPosition) }, onSupport = { context?.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) From 50673f4a49a5099857e396e26973b90ee92bc23b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 12:14:26 +0800 Subject: [PATCH 037/105] Update guide --- .../web3/trade/perps/PerpetualGuidePage.kt | 487 +++++++++++++----- .../android/widget/components/DotText.kt | 50 ++ app/src/main/res/drawable/ic_perps_add.xml | 27 + app/src/main/res/drawable/ic_perps_minus.xml | 20 + app/src/main/res/values-zh-rCN/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 6 files changed, 457 insertions(+), 135 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/widget/components/DotText.kt create mode 100644 app/src/main/res/drawable/ic_perps_add.xml create mode 100644 app/src/main/res/drawable/ic_perps_minus.xml diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 87a799f1ab..5782f16796 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -1,7 +1,12 @@ package one.mixin.android.ui.home.web3.trade.perps -import PageScaffold +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,33 +14,46 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch +import kotlin.math.roundToInt import one.mixin.android.R import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.ui.home.web3.components.OutlinedTab import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.widget.components.DotText data class ScenarioData( val scenario: String, val change: String, - val changeValue: String, - val pnl: String, + val initialPercent: Int = 10, + val basePnlAmount: Int, + val basePnlPercent: Int, + val pnlAsset: String = "USDT", val isProfit: Boolean, ) @@ -52,43 +70,79 @@ fun PerpetualGuidePage(pop: () -> Unit) { stringResource(R.string.Perpetual_Guide_Position) ) - PageScaffold( - title = stringResource(R.string.Trading_Guide), - verticalScrollable = false, - pop = pop - ) { + MixinAppTheme { Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(topEnd = 8.dp, topStart = 8.dp)) + .background(MixinAppTheme.colors.background) ) { - Spacer(modifier = Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { - tabs.forEachIndexed { index, tab -> - OutlinedTab( - text = tab, - selected = selectedTab == index, - showBadge = false, - onClick = { coroutineScope.launch { selectedTab = index } } - ) - if (index < tabs.size - 1) Spacer(modifier = Modifier.width(10.dp)) - } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 20.dp), + ) { + Text( + text = stringResource(R.string.Perpetual), + fontSize = 18.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.align(Alignment.Center), + ) + Icon( + painter = painterResource(id = R.drawable.ic_circle_close), + contentDescription = stringResource(id = R.string.close), + tint = Color.Unspecified, + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { + pop() + }, + ) } - - Spacer(modifier = Modifier.height(16.dp)) - Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) ) { - when (selectedTab) { - 0 -> OverviewContent() - 1 -> LongContent() - 2 -> ShortContent() - 3 -> LeverageContent() - 4 -> PositionContent() + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + tabs.forEachIndexed { index, tab -> + OutlinedTab( + text = tab, + selected = selectedTab == index, + showBadge = false, + onClick = { coroutineScope.launch { selectedTab = index } } + ) + if (index < tabs.size - 1) Spacer(modifier = Modifier.width(10.dp)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + when (selectedTab) { + 0 -> OverviewContent() + 1 -> LongContent() + 2 -> ShortContent() + 3 -> LeverageContent() + 4 -> PositionContent() + } + Spacer(modifier = Modifier.height(24.dp)) } + Spacer(modifier = Modifier.height(20.dp)) + GuideBottomNavigation( + selectedTab = selectedTab, + tabs = tabs, + onSelect = { targetTab -> + coroutineScope.launch { selectedTab = targetTab } + }, + ) Spacer(modifier = Modifier.height(24.dp)) } } @@ -110,24 +164,26 @@ private fun LongContent() { title = stringResource(R.string.Perpetual_Example), rows = listOf( stringResource(R.string.Perpetual_Trading_Pair) to "BTC - USD", - stringResource(R.string.Perpetual_Direction) to "Long", + stringResource(R.string.Perpetual_Direction) to stringResource(R.string.Long), stringResource(R.string.Perpetual_Leverage_Times) to "10x", stringResource(R.string.Perpetual_Investment) to "1,000 USDT" ), scenarios = listOf( ScenarioData( - stringResource(R.string.Perpetual_Scenario_1), - stringResource(R.string.Perpetual_Price_Up), - "10%", - "+100 USDT (+10%)", - true + scenario = stringResource(R.string.Perpetual_Price_Up), + change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + initialPercent = 10, + basePnlAmount = 100, + basePnlPercent = 10, + isProfit = true ), ScenarioData( - stringResource(R.string.Perpetual_Scenario_2), - stringResource(R.string.Perpetual_Price_Down), - "10%", - "-100 USDT (-10%)", - false + scenario = stringResource(R.string.Perpetual_Price_Down), + change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + initialPercent = 10, + basePnlAmount = 100, + basePnlPercent = 10, + isProfit = false ) ) ) @@ -147,24 +203,26 @@ private fun ShortContent() { title = stringResource(R.string.Perpetual_Example), rows = listOf( stringResource(R.string.Perpetual_Trading_Pair) to "ETH - USD", - stringResource(R.string.Perpetual_Direction) to "Short", + stringResource(R.string.Perpetual_Direction) to stringResource(R.string.Short), stringResource(R.string.Perpetual_Leverage_Times) to "10x", stringResource(R.string.Perpetual_Investment) to "1,000 USDT" ), scenarios = listOf( ScenarioData( - stringResource(R.string.Perpetual_Scenario_1), - stringResource(R.string.Perpetual_Price_Down), - "10%", - "+100 USDT (+10%)", - true + scenario = stringResource(R.string.Perpetual_Price_Up), + change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + initialPercent = 10, + basePnlAmount = 100, + basePnlPercent = 10, + isProfit = false ), ScenarioData( - stringResource(R.string.Perpetual_Scenario_2), - stringResource(R.string.Perpetual_Price_Up), - "10%", - "-100 USDT (-10%)", - false + scenario = stringResource(R.string.Perpetual_Price_Down), + change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + initialPercent = 10, + basePnlAmount = 100, + basePnlPercent = 10, + isProfit = true ) ) ) @@ -184,24 +242,26 @@ private fun LeverageContent() { title = stringResource(R.string.Perpetual_Example), rows = listOf( stringResource(R.string.Perpetual_Trading_Pair) to "SOL - USD", - stringResource(R.string.Perpetual_Direction) to "Long", + stringResource(R.string.Perpetual_Direction) to stringResource(R.string.Long), stringResource(R.string.Perpetual_Leverage_Times) to "10x", stringResource(R.string.Perpetual_Investment) to "1,000 USDT" ), scenarios = listOf( ScenarioData( - stringResource(R.string.Perpetual_Scenario_1), - stringResource(R.string.Perpetual_Price_Up), - "10%", - "+1,000 USDT (+100%)", - true + scenario = stringResource(R.string.Perpetual_Price_Up), + change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + initialPercent = 10, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = true ), ScenarioData( - stringResource(R.string.Perpetual_Scenario_2), - stringResource(R.string.Perpetual_Price_Down), - "10%", - "-1,000 USDT (-100%)", - false + scenario = stringResource(R.string.Perpetual_Price_Down), + change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + initialPercent = 10, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = false ) ) ) @@ -220,25 +280,27 @@ private fun PositionContent() { title = stringResource(R.string.Perpetual_Example), rows = listOf( stringResource(R.string.Perpetual_Trading_Pair) to "SOL - USD", - stringResource(R.string.Perpetual_Direction) to "Long", + stringResource(R.string.Perpetual_Direction) to stringResource(R.string.Long), stringResource(R.string.Perpetual_Leverage_Times) to "10x", stringResource(R.string.Perpetual_Investment) to "1,000 USDT", stringResource(R.string.Perpetual_Position_Value) to "10,000 USDT (74.62 SOL)" ), scenarios = listOf( ScenarioData( - stringResource(R.string.Perpetual_Scenario_1), - stringResource(R.string.Perpetual_Price_Up), - "10%", - "+1,000 USDT (+100%)", - true + scenario = stringResource(R.string.Perpetual_Price_Up), + change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + initialPercent = 10, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = true ), ScenarioData( - stringResource(R.string.Perpetual_Scenario_2), - stringResource(R.string.Perpetual_Price_Down), - "10%", - "-1,000 USDT (-100%)", - false + scenario = stringResource(R.string.Perpetual_Price_Down), + change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + initialPercent = 10, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = false ) ) ) @@ -252,6 +314,85 @@ private fun PositionContent() { } +@Composable +private fun GuideBottomNavigation( + selectedTab: Int, + tabs: List, + onSelect: (Int) -> Unit, +) { + val previousTab = (selectedTab - 1).takeIf { it >= 0 } + val nextTab = (selectedTab + 1).takeIf { it < tabs.size } + if (previousTab == null && nextTab == null) { + return + } + if (previousTab != null && nextTab != null) { + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GuideNavigationButton( + text = stringResource(R.string.Perpetual_Guide_Previous_Tab, tabs[previousTab]), + modifier = Modifier.weight(1f), + onClick = { onSelect(previousTab) }, + ) + GuideNavigationButton( + text = stringResource(R.string.Perpetual_Guide_Next_Tab, tabs[nextTab]), + modifier = Modifier.weight(1f), + onClick = { onSelect(nextTab) }, + ) + } + return + } + val targetIndex = previousTab ?: nextTab ?: return + val buttonText = if (previousTab != null) { + stringResource(R.string.Perpetual_Guide_Previous_Tab, tabs[targetIndex]) + } else { + stringResource(R.string.Perpetual_Guide_Next_Tab, tabs[targetIndex]) + } + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + GuideNavigationButton( + text = buttonText, + modifier = Modifier.fillMaxWidth(0.5f), + onClick = { onSelect(targetIndex) }, + ) + } +} + +@Composable +private fun GuideNavigationButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Button( + modifier = modifier.height(48.dp), + onClick = onClick, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MixinAppTheme.colors.accent, + contentColor = Color.White, + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp, + ), + ) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } +} + @Composable private fun GuideSection(title: String, content: String) { Column( @@ -263,7 +404,7 @@ private fun GuideSection(title: String, content: String) { Text( text = title, fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(12.dp)) @@ -271,12 +412,13 @@ private fun GuideSection(title: String, content: String) { text = content, fontSize = 14.sp, lineHeight = 20.sp, - color = MixinAppTheme.colors.textAssist + color = MixinAppTheme.colors.textPrimary ) + Spacer(modifier = Modifier.height(12.dp)) Text( - text = title, + text = stringResource(R.string.Perpetual_Features), fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(8.dp)) @@ -288,31 +430,24 @@ private fun GuideSection(title: String, content: String) { stringResource(R.string.Perpetual_Feature_5) ) .forEach { feature -> - Row(modifier = Modifier.padding(vertical = 4.dp)) { - Text(text = "• ", fontSize = 14.sp, color = MixinAppTheme.colors.textAssist) - Text( - text = feature, - fontSize = 14.sp, - lineHeight = 20.sp, - color = MixinAppTheme.colors.textAssist, - modifier = Modifier.weight(1f) - ) - } + DotText( + text = feature, + modifier = Modifier.padding(vertical = 4.dp), + color = MixinAppTheme.colors.textPrimary, + ) } Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.Perpetual_Risk_Warning), fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textMinor + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(8.dp)) - Text( + DotText( text = stringResource(R.string.Perpetual_Risk_Warning_Content), - fontSize = 14.sp, - lineHeight = 20.sp, - color = MixinAppTheme.colors.textMinor + color = MixinAppTheme.colors.textPrimary ) } } @@ -323,6 +458,14 @@ private fun ExampleWithScenariosCard( rows: List>, scenarios: List, ) { + val changePercents = remember(scenarios.size) { + mutableStateListOf().apply { + addAll(scenarios.map { it.initialPercent.coerceIn(0, 10) }) + } + } + val directionLabel = stringResource(R.string.Perpetual_Direction) + val longDirection = stringResource(R.string.Long) + val shortDirection = stringResource(R.string.Short) Column( modifier = Modifier .fillMaxWidth() @@ -333,11 +476,14 @@ private fun ExampleWithScenariosCard( Text( text = title, fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(12.dp)) rows.forEachIndexed { index, (label, value) -> + if (index > 0) { + Spacer(modifier = Modifier.height(16.dp)) + } Row(modifier = Modifier.fillMaxWidth()) { Text( text = label, @@ -345,12 +491,24 @@ private fun ExampleWithScenariosCard( color = MixinAppTheme.colors.textAssist, modifier = Modifier.weight(1f) ) - Text( - text = value, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary - ) + if (label == directionLabel && (value == longDirection || value == shortDirection)) { + val directionColor = if (value == longDirection) Color(0xFF4CAF50) else Color(0xFFF44336) + Text( + text = value, + fontSize = 14.sp, + color = Color.White, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(directionColor) + .padding(horizontal = 8.dp, vertical = 1.dp), + ) + } else { + Text( + text = value, + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + } } } @@ -358,19 +516,33 @@ private fun ExampleWithScenariosCard( scenarios.forEachIndexed { index, scenario -> if (index > 0) { - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(16.dp)) + } + val scenarioTitle = when (index) { + 0 -> stringResource(R.string.Perpetual_Scenario_1) + 1 -> stringResource(R.string.Perpetual_Scenario_2) + else -> "" } Column( modifier = Modifier .fillMaxWidth() ) { + if (scenarioTitle.isNotEmpty()) { + Text( + text = scenarioTitle, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(6.dp)) + } Text( text = scenario.scenario, fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textPrimary + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth()) { Text( text = scenario.change, @@ -378,12 +550,55 @@ private fun ExampleWithScenariosCard( color = MixinAppTheme.colors.textAssist, modifier = Modifier.weight(1f) ) - Text( - text = scenario.changeValue, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = if (scenario.isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) + val percent = changePercents[index] + Row( + verticalAlignment = Alignment.CenterVertically, ) + { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow) + .alpha(if (percent > 0) 1f else 0.5f) + .clickable(enabled = percent > 0) { + changePercents[index] = (percent - 1).coerceAtLeast(0) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_minus), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = "$percent%", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.padding(horizontal = 8.dp), + ) + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow) + .alpha(if (percent < 10) 1f else 0.5f) + .clickable(enabled = percent < 10) { + changePercents[index] = (percent + 1).coerceAtMost(10) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_add), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp), + ) + } + } } Spacer(modifier = Modifier.height(4.dp)) Row(modifier = Modifier.fillMaxWidth()) { @@ -394,7 +609,7 @@ private fun ExampleWithScenariosCard( modifier = Modifier.weight(1f) ) Text( - text = scenario.pnl, + text = scenario.formatPnl(changePercents[index]), fontSize = 14.sp, fontWeight = FontWeight.Bold, color = if (scenario.isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) @@ -405,6 +620,16 @@ private fun ExampleWithScenariosCard( } } +private fun ScenarioData.formatPnl(currentPercent: Int): String { + val safeInitialPercent = initialPercent.coerceAtLeast(1) + val safeCurrentPercent = currentPercent.coerceIn(0, 10) + val amount = (basePnlAmount.toFloat() * safeCurrentPercent / safeInitialPercent).roundToInt() + val percent = (basePnlPercent.toFloat() * safeCurrentPercent / safeInitialPercent).roundToInt() + val sign = if (isProfit) "+" else "-" + val amountText = String.format("%,d", amount) + return "$sign$amountText $pnlAsset ($sign$percent%)" +} + @Composable private fun DescriptionWithRulesCard( description: String, @@ -420,7 +645,7 @@ private fun DescriptionWithRulesCard( Text( text = stringResource(R.string.Perpetual_Detail_Desc), fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(8.dp)) @@ -428,7 +653,7 @@ private fun DescriptionWithRulesCard( text = description, fontSize = 14.sp, lineHeight = 20.sp, - color = MixinAppTheme.colors.textAssist + color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(16.dp)) @@ -436,21 +661,16 @@ private fun DescriptionWithRulesCard( Text( text = stringResource(R.string.Perpetual_PnL_Rules), fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(12.dp)) rules.forEach { (condition, result) -> - Row(modifier = Modifier.padding(vertical = 4.dp)) { - Text(text = "$condition:", fontSize = 14.sp, color = MixinAppTheme.colors.textAssist) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = result, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary - ) - } + DotText( + text = "$condition:$result", + modifier = Modifier.padding(vertical = 4.dp), + color = MixinAppTheme.colors.textPrimary, + ) } } } @@ -471,7 +691,7 @@ private fun DescriptionWithInfoAndRiskCard( Text( text = stringResource(R.string.Perpetual_Detail_Desc), fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(8.dp)) @@ -479,7 +699,7 @@ private fun DescriptionWithInfoAndRiskCard( text = description, fontSize = 14.sp, lineHeight = 20.sp, - color = MixinAppTheme.colors.textAssist + color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(16.dp)) @@ -491,7 +711,7 @@ private fun DescriptionWithInfoAndRiskCard( Text( text = infoTitle, fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textMinor ) Spacer(modifier = Modifier.height(6.dp)) @@ -499,7 +719,7 @@ private fun DescriptionWithInfoAndRiskCard( text = infoContent, fontSize = 14.sp, lineHeight = 18.sp, - color = MixinAppTheme.colors.textMinor + color = MixinAppTheme.colors.textPrimary ) } @@ -512,17 +732,14 @@ private fun DescriptionWithInfoAndRiskCard( Text( text = stringResource(R.string.Perpetual_Risk_Warning), fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = MixinAppTheme.colors.textMinor + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(6.dp)) - Text( + DotText( text = riskContent, - fontSize = 14.sp, - lineHeight = 18.sp, - color = MixinAppTheme.colors.textMinor + color = MixinAppTheme.colors.textPrimary ) } } } - diff --git a/app/src/main/java/one/mixin/android/widget/components/DotText.kt b/app/src/main/java/one/mixin/android/widget/components/DotText.kt new file mode 100644 index 0000000000..abb7febd3c --- /dev/null +++ b/app/src/main/java/one/mixin/android/widget/components/DotText.kt @@ -0,0 +1,50 @@ +package one.mixin.android.widget.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import one.mixin.android.compose.theme.MixinAppTheme + +@Composable +fun DotText( + text: String, + modifier: Modifier = Modifier, + dotSize: Dp = 5.dp, + color: Color = MixinAppTheme.colors.textAssist, + style: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp), +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Spacer(modifier= Modifier.width(5.dp)) + Box( + modifier = Modifier + .padding(end = 6.dp, top = 5.dp) + .size(dotSize) + .background(color = color, shape = CircleShape), + ) + Spacer(modifier= Modifier.width(5.dp)) + Text( + text = text, + style = style, + color = color, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/res/drawable/ic_perps_add.xml b/app/src/main/res/drawable/ic_perps_add.xml new file mode 100644 index 0000000000..fc87311528 --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_add.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_perps_minus.xml b/app/src/main/res/drawable/ic_perps_minus.xml new file mode 100644 index 0000000000..700121d210 --- /dev/null +++ b/app/src/main/res/drawable/ic_perps_minus.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index efc710f6b5..05eeeb491a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2228,6 +2228,8 @@ 做空 杠杆 平仓 + <%1$s + %1$s> 永续合约允许您使用杠杆交易加密货币,从而放大您的潜在利润(和损失)。您可以做多(押注价格上涨)或做空(押注价格下跌),而无需拥有标的资产。 做多意味着您预期价格会上涨。如果价格上涨,您就会获利。如果价格下跌,您就会亏损。您的盈亏会被杠杆倍数放大。 做空意味着您预期价格会下跌。如果价格下跌,您就会获利。如果价格上涨,您就会亏损。您的盈亏会被杠杆倍数放大。 @@ -2258,6 +2260,8 @@ 场景二:价格下跌 价格上涨 价格下跌 + 上涨幅度 + 下跌幅度 盈亏 具体说明 做多是指在预期价格上涨时建立仓位,价格上涨可获得盈利,价格下跌则产生亏损。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8820f1c1b0..0620a0cf09 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2287,6 +2287,8 @@ Short Leverage Close Position + <%1$s + %1$s> Perpetual contracts allow you to trade cryptocurrency with leverage, enabling you to amplify your potential profits (and losses). You can go long (bet on price increase) or short (bet on price decrease) without owning the underlying asset. Going long means you expect the price to increase. If the price goes up, you profit. If it goes down, you lose. Your profit/loss is multiplied by your leverage. Going short means you expect the price to decrease. If the price goes down, you profit. If it goes up, you lose. Your profit/loss is multiplied by your leverage. @@ -2317,6 +2319,8 @@ Scenario 2: Price Fall Price Rise Price Fall + Upward Change + Downward Change P&L Detailed Description Going long means establishing a position when expecting price to rise. Profit when price rises, loss when price falls. From 5fa0d6a19cb165e3af87a7ff59236f34b5d11ff2 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 12:34:26 +0800 Subject: [PATCH 038/105] Update direction color --- .../ui/home/web3/trade/perps/PerpetualContent.kt | 4 ++-- .../ui/home/web3/trade/perps/PerpetualGuidePage.kt | 12 ++++++++++-- .../perps/PerpsCloseBottomSheetDialogFragment.kt | 13 +++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) 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 9ead886947..8388579be9 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 @@ -429,7 +429,7 @@ fun PerpetualContent( .height(48.dp), shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = Color(0xFF4CAF50), + backgroundColor = risingColor, contentColor = Color.White ), enabled = markets.isNotEmpty() @@ -450,7 +450,7 @@ fun PerpetualContent( .height(48.dp), shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = Color(0xFFF44336), + backgroundColor = fallingColor, contentColor = Color.White ), enabled = markets.isNotEmpty() diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 5782f16796..68423a51be 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -41,8 +42,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import kotlin.math.roundToInt +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.ui.home.web3.components.OutlinedTab import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.widget.components.DotText @@ -458,6 +461,11 @@ private fun ExampleWithScenariosCard( rows: List>, scenarios: List, ) { + val context = LocalContext.current + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed val changePercents = remember(scenarios.size) { mutableStateListOf().apply { addAll(scenarios.map { it.initialPercent.coerceIn(0, 10) }) @@ -492,7 +500,7 @@ private fun ExampleWithScenariosCard( modifier = Modifier.weight(1f) ) if (label == directionLabel && (value == longDirection || value == shortDirection)) { - val directionColor = if (value == longDirection) Color(0xFF4CAF50) else Color(0xFFF44336) + val directionColor = if (value == longDirection) risingColor else fallingColor Text( text = value, fontSize = 14.sp, @@ -612,7 +620,7 @@ private fun ExampleWithScenariosCard( text = scenario.formatPnl(changePercents[index]), fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = if (scenario.isProfit) Color(0xFF4CAF50) else Color(0xFFF44336) + color = if (scenario.isProfit) risingColor else fallingColor ) } } 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 5abcd03812..b896cfadd6 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 @@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -50,12 +51,14 @@ import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.booleanFromAttribute import one.mixin.android.extension.composeDp +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.isNightMode import one.mixin.android.extension.screenHeight @@ -169,6 +172,10 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen @Composable override fun ComposeContent() { + val context = LocalContext.current + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + LaunchedEffect(Unit) { latestMarkPrice = markPrice latestUnrealizedPnl = unrealizedPnl @@ -335,10 +342,12 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } catch (e: Exception) { BigDecimal.ZERO } + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed val pnlColor = if (pnl >= BigDecimal.ZERO) { - MixinAppTheme.colors.walletGreen + risingColor } else { - MixinAppTheme.colors.walletRed + fallingColor } val estimatedReceive = try { From 4d069c166b128c2382c3c112a588572322743864 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 13:02:42 +0800 Subject: [PATCH 039/105] Update position detail --- .../web3/trade/perps/PerpetualGuidePage.kt | 133 +++++++++++++----- .../web3/trade/perps/PositionDetailPage.kt | 62 ++++---- app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 4 files changed, 134 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 68423a51be..7bc217c40c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3.trade.perps +import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -60,6 +61,12 @@ data class ScenarioData( val isProfit: Boolean, ) +data class GuideRowData( + val label: String, + val value: String, + @DrawableRes val iconRes: Int? = null, +) + @Composable fun PerpetualGuidePage(pop: () -> Unit) { var selectedTab by remember { mutableIntStateOf(0) } @@ -90,7 +97,7 @@ fun PerpetualGuidePage(pop: () -> Unit) { fontSize = 18.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary, - modifier = Modifier.align(Alignment.Center), + modifier = Modifier.align(Alignment.Start), ) Icon( painter = painterResource(id = R.drawable.ic_circle_close), @@ -166,10 +173,23 @@ private fun LongContent() { ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( - stringResource(R.string.Perpetual_Trading_Pair) to "BTC - USD", - stringResource(R.string.Perpetual_Direction) to stringResource(R.string.Long), - stringResource(R.string.Perpetual_Leverage_Times) to "10x", - stringResource(R.string.Perpetual_Investment) to "1,000 USDT" + GuideRowData( + label = stringResource(R.string.Perpetual_Trading_Pair), + value = "BTC - USD", + iconRes = R.drawable.ic_chain_btc + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Direction), + value = stringResource(R.string.Long) + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Leverage_Times), + value = "10x" + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Investment), + value = "1,000 USDT" + ) ), scenarios = listOf( ScenarioData( @@ -205,10 +225,23 @@ private fun ShortContent() { ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( - stringResource(R.string.Perpetual_Trading_Pair) to "ETH - USD", - stringResource(R.string.Perpetual_Direction) to stringResource(R.string.Short), - stringResource(R.string.Perpetual_Leverage_Times) to "10x", - stringResource(R.string.Perpetual_Investment) to "1,000 USDT" + GuideRowData( + label = stringResource(R.string.Perpetual_Trading_Pair), + value = "ETH - USD", + iconRes = R.drawable.ic_chain_eth + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Direction), + value = stringResource(R.string.Short) + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Leverage_Times), + value = "10x" + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Investment), + value = "1,000 USDT" + ) ), scenarios = listOf( ScenarioData( @@ -244,10 +277,23 @@ private fun LeverageContent() { ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( - stringResource(R.string.Perpetual_Trading_Pair) to "SOL - USD", - stringResource(R.string.Perpetual_Direction) to stringResource(R.string.Long), - stringResource(R.string.Perpetual_Leverage_Times) to "10x", - stringResource(R.string.Perpetual_Investment) to "1,000 USDT" + GuideRowData( + label = stringResource(R.string.Perpetual_Trading_Pair), + value = "SOL - USD", + iconRes = R.drawable.ic_chain_sol + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Direction), + value = stringResource(R.string.Long) + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Leverage_Times), + value = "10x" + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Investment), + value = "1,000 USDT" + ) ), scenarios = listOf( ScenarioData( @@ -282,11 +328,27 @@ private fun PositionContent() { ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( - stringResource(R.string.Perpetual_Trading_Pair) to "SOL - USD", - stringResource(R.string.Perpetual_Direction) to stringResource(R.string.Long), - stringResource(R.string.Perpetual_Leverage_Times) to "10x", - stringResource(R.string.Perpetual_Investment) to "1,000 USDT", - stringResource(R.string.Perpetual_Position_Value) to "10,000 USDT (74.62 SOL)" + GuideRowData( + label = stringResource(R.string.Perpetual_Trading_Pair), + value = "SOL - USD", + iconRes = R.drawable.ic_chain_sol + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Direction), + value = stringResource(R.string.Long) + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Leverage_Times), + value = "10x" + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Investment), + value = "1,000 USDT" + ), + GuideRowData( + label = stringResource(R.string.Perpetual_Position_Value), + value = "10,000 USDT (74.62 SOL)" + ) ), scenarios = listOf( ScenarioData( @@ -458,7 +520,7 @@ private fun GuideSection(title: String, content: String) { @Composable private fun ExampleWithScenariosCard( title: String, - rows: List>, + rows: List, scenarios: List, ) { val context = LocalContext.current @@ -488,7 +550,9 @@ private fun ExampleWithScenariosCard( color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(12.dp)) - rows.forEachIndexed { index, (label, value) -> + rows.forEachIndexed { index, row -> + val label = row.label + val value = row.value if (index > 0) { Spacer(modifier = Modifier.height(16.dp)) } @@ -511,11 +575,22 @@ private fun ExampleWithScenariosCard( .padding(horizontal = 8.dp, vertical = 1.dp), ) } else { - Text( - text = value, - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary - ) + Row(verticalAlignment = Alignment.CenterVertically) { + row.iconRes?.let { iconRes -> + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text( + text = value, + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + } } } } @@ -544,12 +619,6 @@ private fun ExampleWithScenariosCard( ) Spacer(modifier = Modifier.height(6.dp)) } - Text( - text = scenario.scenario, - fontSize = 14.sp, - fontWeight = FontWeight.W500, - color = MixinAppTheme.colors.textAssist - ) Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth()) { Text( @@ -608,7 +677,7 @@ private fun ExampleWithScenariosCard( } } } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth()) { Text( text = stringResource(R.string.Perpetual_PnL), 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 bec3bf6e0b..82ae47dab1 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 @@ -67,23 +67,16 @@ fun PositionDetailPage( } } - val pnl = try { - BigDecimal(position.unrealizedPnl ?: "0") - } catch (e: Exception) { - BigDecimal.ZERO - } - - val isProfit = pnl >= BigDecimal.ZERO val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed - val pnlColor = if (isProfit) risingColor else fallingColor - - val sideText = if (position.side.lowercase() == "long") { + val isLong = position.side.equals("long", ignoreCase = true) + val sideColor = if (isLong) risingColor else fallingColor + val sideText = if (isLong) { stringResource(R.string.Long) } else { stringResource(R.string.Short) } - val title = "Opened $sideText" + val title = stringResource(R.string.Perpetual_Opened_Side_Title, sideText) val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO @@ -95,6 +88,14 @@ fun PositionDetailPage( return "$fiatSymbol${value.multiply(fiatRate).priceFormat()}" } + fun formatSignedFiat(value: BigDecimal): String { + return when { + value > BigDecimal.ZERO -> "+${formatFiat(value)}" + value < BigDecimal.ZERO -> "-${formatFiat(value.abs())}" + else -> formatFiat(BigDecimal.ZERO) + } + } + PageScaffold( title = title, verticalScrollable = false, @@ -137,11 +138,6 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { - val sideText = if (position.side.lowercase() == "long") { - stringResource(R.string.Long) - } else { - stringResource(R.string.Short) - } Text( text = "$sideText ", fontSize = 24.sp, @@ -161,18 +157,13 @@ fun PositionDetailPage( Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) - .background(pnlColor.copy(alpha = 0.2f)) + .background(sideColor.copy(alpha = 0.2f)) .padding(horizontal = 8.dp, vertical = 4.dp) .align(Alignment.CenterHorizontally) ) { - val sideText = if (position.side.lowercase() == "long") { - stringResource(R.string.Long) - } else { - stringResource(R.string.Short) - } Text( text = "$sideText ${position.leverage}x", - color = pnlColor, + color = sideColor, fontSize = 14.sp ) } @@ -361,13 +352,15 @@ fun PositionDetailPage( val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed val pnlColor = if (isProfit) risingColor else fallingColor + val isLong = positionHistory.side.equals("long", ignoreCase = true) + val sideColor = if (isLong) risingColor else fallingColor - val sideText = if (positionHistory.side.lowercase() == "long") { + val sideText = if (isLong) { stringResource(R.string.Long) } else { stringResource(R.string.Short) } - val title = "Closed $sideText" + val title = stringResource(R.string.Perpetual_Closed_Side_Title, sideText) val quantity = positionHistory.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val closePrice = positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO @@ -379,6 +372,14 @@ fun PositionDetailPage( return "$fiatSymbol${value.multiply(fiatRate).priceFormat()}" } + fun formatSignedFiat(value: BigDecimal): String { + return when { + value > BigDecimal.ZERO -> "+${formatFiat(value)}" + value < BigDecimal.ZERO -> "-${formatFiat(value.abs())}" + else -> formatFiat(BigDecimal.ZERO) + } + } + PageScaffold( title = title, verticalScrollable = false, @@ -421,7 +422,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) Text( - text = formatFiat(pnl.abs()), + text = formatSignedFiat(pnl), fontSize = 24.sp, fontWeight = FontWeight.W500, color = pnlColor, @@ -433,18 +434,13 @@ fun PositionDetailPage( Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) - .background(pnlColor.copy(alpha = 0.2f)) + .background(sideColor.copy(alpha = 0.2f)) .padding(horizontal = 8.dp, vertical = 4.dp) .align(Alignment.CenterHorizontally) ) { - val sideText = if (positionHistory.side.lowercase() == "long") { - stringResource(R.string.Long) - } else { - stringResource(R.string.Short) - } Text( text = "$sideText ${positionHistory.leverage}x", - color = pnlColor, + color = sideColor, fontSize = 14.sp ) } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 05eeeb491a..a7f64deb2d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2308,6 +2308,8 @@ 确认平仓 平仓成功 平仓 + %1$s持仓 + %1$s平仓 预估强平价格 持仓总价值 %1$s %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0620a0cf09..4872b9c6fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2373,6 +2373,8 @@ Confirm Close Position Close Position Success Close Position + Opened %1$s + Closed %1$s Estimated Liquidation Price Total Position Value %1$s %2$s From 2db4ace25a8fc5a4a662586d9924230c8eae54fb Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 13:50:41 +0800 Subject: [PATCH 040/105] Update red dot logic --- .../main/java/one/mixin/android/Constants.kt | 1 + .../ui/home/web3/trade/TradeFragment.kt | 10 ++++++ .../android/ui/home/web3/trade/TradePage.kt | 16 +++++++-- .../trade/perps/PerpetualGuideFragment.kt | 33 +++++++++++++++++++ .../web3/trade/perps/PerpetualGuidePage.kt | 2 +- 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index 1b476e5190..b0c402718a 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -115,6 +115,7 @@ object Constants { const val PREF_HAS_USED_MARKET = "pref_has_used_market" const val PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED = "pref_trade_limit_order_badge_dismissed" + const val PREF_TRADE_PERPETUAL_BADGE_DISMISSED = "pref_trade_perpetual_badge_dismissed" const val PREF_USED_WALLET = "pref_used_wallet" 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 0ed40b1250..681080a1e0 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 @@ -247,6 +247,9 @@ class TradeFragment : BaseFragment() { var isLimitOrderTabBadgeDismissed by remember(currentWalletId) { mutableStateOf(defaultSharedPreferences.getBoolean(Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED, false)) } + var isPerpetualTabBadgeDismissed by remember(currentWalletId) { + mutableStateOf(defaultSharedPreferences.getBoolean(Account.PREF_TRADE_PERPETUAL_BADGE_DISMISSED, false)) + } if (!isLimitOrderTabBadgeDismissed) { isLimitOrderTabBadgeDismissed = true @@ -261,6 +264,7 @@ class TradeFragment : BaseFragment() { inMixin = inMixin(), orderBadge = orderBadge, isLimitOrderTabBadgeDismissed = isLimitOrderTabBadgeDismissed, + isPerpetualTabBadgeDismissed = isPerpetualTabBadgeDismissed, initialAmount = initialAmount, lastOrderTime = lastOrderTime, reviewing = reviewing, @@ -279,6 +283,12 @@ class TradeFragment : BaseFragment() { defaultSharedPreferences.putBoolean(Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED, true) } }, + onDismissPerpetualTabBadge = { + if (!isPerpetualTabBadgeDismissed) { + isPerpetualTabBadgeDismissed = true + defaultSharedPreferences.putBoolean(Account.PREF_TRADE_PERPETUAL_BADGE_DISMISSED, true) + } + }, onTabChanged = { index -> val preferenceKey = "$PREF_TRADE_SELECTED_TAB_PREFIX$currentWalletId" defaultSharedPreferences.putInt(preferenceKey, index) 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 ae543c48ee..c238371782 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 @@ -77,6 +77,7 @@ fun TradePage( inMixin: Boolean, orderBadge: Boolean, isLimitOrderTabBadgeDismissed: Boolean, + isPerpetualTabBadgeDismissed: Boolean, initialAmount: String?, lastOrderTime: Long?, reviewing: Boolean, @@ -88,6 +89,7 @@ fun TradePage( onDeposit: (SwapToken) -> Unit, onOrderList: (String, Boolean) -> Unit, onDismissLimitOrderTabBadge: () -> Unit, + onDismissPerpetualTabBadge: () -> Unit, onTabChanged: (Int) -> Unit, onSwitchToLimitOrder: (String, SwapToken, SwapToken) -> Unit, pop: () -> Unit, @@ -175,7 +177,9 @@ fun TradePage( ) } + var perpetualTabIndex: Int? = null if (walletId == null) { + perpetualTabIndex = tabs.size tabs += TabItem(title = stringResource(R.string.Perpetual)) { PerpetualContent( onShowTradingGuide = onShowTradingGuide, @@ -215,7 +219,7 @@ fun TradePage( sheetBackgroundColor = MixinAppTheme.colors.background, sheetContent = { HelpBottomSheetContent( - hideGuide = pagerState.currentPage != 2, + hideGuide = perpetualTabIndex == null || pagerState.currentPage != perpetualTabIndex, onContactSupport = { coroutineScope.launch { bottomSheetState.hide() @@ -270,7 +274,7 @@ fun TradePage( Box { IconButton(onClick = { // If on Perpetual tab (page 2), show closed positions - if (walletId == null && pagerState.currentPage == 2) { + if (perpetualTabIndex != null && pagerState.currentPage == perpetualTabIndex) { onShowAllClosedPositions() } else { onOrderList(currentWalletId, false) @@ -334,10 +338,12 @@ fun TradePage( tabs.forEachIndexed { index, tab -> val isAdvancedTab: Boolean = index == 1 val showAdvancedBadge: Boolean = isAdvancedTab && !isLimitOrderTabBadgeDismissed + val isPerpetualTab: Boolean = perpetualTabIndex != null && index == perpetualTabIndex + val showPerpetualBadge: Boolean = isPerpetualTab && !isPerpetualTabBadgeDismissed OutlinedTab( text = tab.title, selected = pagerState.currentPage == index, - showBadge = showAdvancedBadge, + showBadge = showAdvancedBadge || showPerpetualBadge, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) @@ -345,6 +351,10 @@ fun TradePage( if (isAdvancedTab) { onDismissLimitOrderTabBadge() } + if (isPerpetualTab && !isPerpetualTabBadgeDismissed) { + onDismissPerpetualTabBadge() + onShowTradingGuide() + } onTabChanged(index) }, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt index ed8271e3e4..a669ea26f1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt @@ -1,12 +1,20 @@ package one.mixin.android.ui.home.web3.trade.perps +import android.annotation.SuppressLint +import android.app.Dialog +import android.view.Gravity import android.view.View +import android.view.ViewGroup import androidx.compose.runtime.Composable import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode import one.mixin.android.extension.screenHeight import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.util.SystemUIManager @AndroidEntryPoint class PerpetualGuideFragment : MixinComposeBottomSheetDialogFragment() { @@ -17,6 +25,31 @@ class PerpetualGuideFragment : MixinComposeBottomSheetDialogFragment() { fun newInstance() = PerpetualGuideFragment() } + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + @Composable override fun ComposeContent() { MixinAppTheme { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 7bc217c40c..a14020f728 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -97,7 +97,7 @@ fun PerpetualGuidePage(pop: () -> Unit) { fontSize = 18.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary, - modifier = Modifier.align(Alignment.Start), + modifier = Modifier.align(Alignment.CenterStart), ) Icon( painter = painterResource(id = R.drawable.ic_circle_close), From c30c29bb89a2a8d46c182ef6aec0b6bc58856d7f Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 14:12:43 +0800 Subject: [PATCH 041/105] feat(perps): cache accepted assets and restrict TYPE_FROM_PERP token list --- .../main/java/one/mixin/android/Constants.kt | 1 + .../mixin/android/api/service/RouteService.kt | 3 +++ .../home/web3/trade/perps/OpenPositionPage.kt | 14 +++++++--- .../home/web3/trade/perps/PerpetualContent.kt | 9 +++++++ .../web3/trade/perps/PerpetualViewModel.kt | 26 +++++++++++++++++++ .../TokenListBottomSheetDialogFragment.kt | 13 +++++++++- 6 files changed, 62 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index b0c402718a..0f412d141a 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -116,6 +116,7 @@ object Constants { const val PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED = "pref_trade_limit_order_badge_dismissed" const val PREF_TRADE_PERPETUAL_BADGE_DISMISSED = "pref_trade_perpetual_badge_dismissed" + const val PREF_PERPS_ACCEPTED_ASSET_IDS = "pref_perps_accepted_asset_ids" const val PREF_USED_WALLET = "pref_used_wallet" 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 744b220b83..ade34ea9a9 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 @@ -371,6 +371,9 @@ interface RouteService { @Query("time_frame") timeFrame: String ): MixinResponse + @GET("perps/orders/accepted-assets") + suspend fun getAcceptedAssets(): MixinResponse> + @POST("perps/orders/open") suspend fun openPerpsOrder( @Body request: OpenOrderRequest 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 8c0d877f5c..c4349c9a06 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 @@ -85,6 +85,13 @@ fun OpenPositionPage( ) { val context = LocalContext.current val viewModel = hiltViewModel() + val acceptedPerpAssetIds = remember { + context.defaultSharedPreferences + .getStringSet(Constants.Account.PREF_PERPS_ACCEPTED_ASSET_IDS, emptySet()) + .orEmpty() + .filter { it.isNotBlank() } + .toSet() + } var market by remember { mutableStateOf(null) } var currentToken by remember { mutableStateOf(selectedToken) } @@ -104,10 +111,11 @@ fun OpenPositionPage( ) viewModel.loadUsdTokens { tokens -> - availableTokens = tokens + val supportedTokens = tokens.filter { it.assetId in acceptedPerpAssetIds } + availableTokens = supportedTokens currentToken = selectedToken?.let { target -> - tokens.firstOrNull { it.assetId == target.assetId } ?: target - } ?: tokens.firstOrNull() + supportedTokens.firstOrNull { it.assetId == target.assetId } + } ?: supportedTokens.firstOrNull() } } 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 8388579be9..7c5740434c 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 @@ -53,6 +53,7 @@ import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.putStringSet import one.mixin.android.extension.priceFormat import one.mixin.android.session.Session import one.mixin.android.ui.home.web3.trade.ClosedPositionItem @@ -129,6 +130,14 @@ fun PerpetualContent( LaunchedEffect(walletId, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.loadAcceptedAssets( + onSuccess = { assetIds -> + context.defaultSharedPreferences.putStringSet( + Constants.Account.PREF_PERPS_ACCEPTED_ASSET_IDS, + assetIds.toSet() + ) + } + ) while (isActive) { viewModel.refreshPositions(walletId) viewModel.refreshPositionHistory(walletId, limit = CLOSED_POSITION_PREVIEW_LIMIT) 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 d63f2f53ae..41351826e8 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 @@ -181,6 +181,32 @@ class PerpetualViewModel @Inject constructor( } } + fun loadAcceptedAssets( + onSuccess: (List) -> Unit, + onError: (String) -> Unit = {} + ) { + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { + routeService.getAcceptedAssets() + } + + val data = response.data + if (response.isSuccess && data != null) { + onSuccess(data.filter { it.isNotBlank() }) + } else { + val error = "Failed to load accepted assets: ${response.errorDescription}" + Timber.e(error) + onError(error) + } + } catch (e: Exception) { + val error = "Error loading accepted assets: ${e.message}" + Timber.e(e, error) + onError(error) + } + } + } + fun loadUsdTokens(onSuccess: (List) -> Unit) { viewModelScope.launch { try { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt index 46d39435be..59e12daf3a 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt @@ -99,6 +99,13 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { private var currentQuery: String = "" private var defaultAssets = emptyList() private var currentChain: String? = null + private val acceptedPerpAssetIds: Set by lazy { + requireContext().defaultSharedPreferences + .getStringSet(Constants.Account.PREF_PERPS_ACCEPTED_ASSET_IDS, emptySet()) + .orEmpty() + .filter { it.isNotBlank() } + .toSet() + } private fun initRadio() { binding.apply { @@ -238,7 +245,11 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } else { bottomViewModel.assetItemsNotHidden() }.observe(this) { - defaultAssets = it + defaultAssets = if (fromType == TYPE_FROM_PERP) { + it.filter { token -> token.assetId in acceptedPerpAssetIds } + } else { + it + } if (fromType == TYPE_FROM_SEND) { adapter.submitList(defaultAssets) if (defaultAssets.isEmpty()) { From f64f010fbd26a58ea85056a1173096e904bdef29 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 15:15:12 +0800 Subject: [PATCH 042/105] Update --- .../ui/home/web3/components/OpenOrderItem.kt | 18 ++- .../trade/perps/AllPerpsMarketsFragment.kt | 40 +------ .../web3/trade/perps/OpenPositionAdapter.kt | 2 +- .../home/web3/trade/perps/OpenPositionItem.kt | 25 ++-- .../home/web3/trade/perps/OpenPositionPage.kt | 112 +++++++++++------- .../home/web3/trade/perps/PerpetualContent.kt | 3 +- .../web3/trade/perps/PerpetualGuidePage.kt | 25 ++++ .../web3/trade/perps/PerpetualViewModel.kt | 10 +- .../ui/home/web3/trade/perps/PerpsActivity.kt | 50 +++++--- .../PerpsCloseBottomSheetDialogFragment.kt | 13 +- .../PerpsConfirmBottomSheetDialogFragment.kt | 7 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 6 +- .../home/web3/trade/perps/PerpsMarketItem.kt | 11 +- .../trade/perps/PerpsMarketListAdapter.kt | 2 +- ...erpsMarketListBottomSheetDialogFragment.kt | 2 +- .../web3/trade/perps/PositionDetailPage.kt | 10 +- .../one/mixin/android/util/ErrorHandler.kt | 4 + .../res/drawable/bg_perps_leverage_long.xml | 5 +- .../res/drawable/bg_perps_leverage_short.xml | 5 +- .../main/res/drawable/bg_perps_share_tag.xml | 5 +- app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 22 files changed, 207 insertions(+), 152 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/OpenOrderItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/OpenOrderItem.kt index 0d29990020..3fa68fb574 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/OpenOrderItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/OpenOrderItem.kt @@ -26,9 +26,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.fullDate import one.mixin.android.extension.numberFormat import one.mixin.android.ui.home.web3.trade.SwapViewModel @@ -40,6 +42,11 @@ fun OpenOrderItem(order: Order, onClick: () -> Unit) { val viewModel = hiltViewModel() val fromToken by viewModel.assetItemFlow(order.payAssetId).collectAsStateWithLifecycle(null) val toToken by viewModel.assetItemFlow(order.receiveAssetId).collectAsStateWithLifecycle(null) + val context = LocalContext.current + val quoteColorReversed = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed Row( modifier = Modifier .fillMaxWidth() @@ -91,7 +98,7 @@ fun OpenOrderItem(order: Order, onClick: () -> Unit) { Text( text = "-${payAmountText} ${fromToken?.symbol ?: ""}", fontSize = 14.sp, - color = MixinAppTheme.colors.walletRed, + color = fallingColor, ) Spacer(modifier = Modifier.weight(1f)) val typeText = when (order.orderType.lowercase()) { @@ -120,8 +127,8 @@ fun OpenOrderItem(order: Order, onClick: () -> Unit) { // Pending orders without received amount should be gray val leftColor = when { orderState.isPending() && !hasReceivedAmount -> MixinAppTheme.colors.textAssist - orderState.isDone() -> MixinAppTheme.colors.walletGreen - else -> MixinAppTheme.colors.walletRed + orderState.isDone() -> risingColor + else -> fallingColor } Text( text = "+${receiveAmountText} ${toToken?.symbol ?: ""}", @@ -131,10 +138,9 @@ fun OpenOrderItem(order: Order, onClick: () -> Unit) { Spacer(modifier = Modifier.weight(1f)) val rightColor = when { orderState.isPending() -> MixinAppTheme.colors.textAssist - orderState.isDone() -> MixinAppTheme.colors.walletGreen - else -> MixinAppTheme.colors.walletRed + orderState.isDone() -> risingColor + else -> fallingColor } - val context = LocalContext.current val stateText = orderState.format(context) Text( text = stateText, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt index e0eab32daf..d3e4406dc1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.fragment.app.viewModels import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint @@ -41,10 +40,7 @@ import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.isNightMode -import one.mixin.android.extension.toast import one.mixin.android.ui.common.BaseFragment -import one.mixin.android.ui.home.web3.trade.SwapViewModel -import one.mixin.android.vo.market.MarketItem @AndroidEntryPoint class AllPerpsMarketsFragment : BaseFragment() { @@ -55,7 +51,6 @@ class AllPerpsMarketsFragment : BaseFragment() { fun newInstance() = AllPerpsMarketsFragment() } - private val swapViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, @@ -78,43 +73,16 @@ class AllPerpsMarketsFragment : BaseFragment() { } } - private suspend fun showMarketDetails(market: PerpsMarket) { - val marketItem = findMarketItemByPerpsMarket(market) - if (marketItem != null && activity != null) { - PerpsActivity.Companion.showDetail( + private fun showMarketDetails(market: PerpsMarket) { + if (activity != null) { + PerpsActivity.showDetail( requireContext(), market.marketId, market.symbol, market.displaySymbol ) - } else { - toast(R.string.Alert_Not_Support) } } - - private suspend fun findMarketItemByPerpsMarket(market: PerpsMarket): MarketItem? { - val symbols = linkedSetOf( - market.tokenSymbol, - market.displaySymbol.substringBefore("/"), - market.displaySymbol.substringBefore("-"), - market.symbol.substringBefore("-"), - market.symbol.substringBefore("_"), - ).map { it.trim().uppercase() }.filter { it.isNotEmpty() } - - for (symbol in symbols) { - val tokens = runCatching { swapViewModel.searchTokens(symbol, true) } - .getOrNull() - ?.data - .orEmpty() - val token = tokens.firstOrNull { it.symbol.equals(symbol, ignoreCase = true) } ?: tokens.firstOrNull() - val assetId = token?.assetId ?: continue - val marketItem = runCatching { swapViewModel.checkMarketById(assetId, false) }.getOrNull() - if (marketItem != null) { - return marketItem - } - } - return null - } } @Composable @@ -144,7 +112,7 @@ private fun AllMarketsPage( } PageScaffold( - title = stringResource(R.string.Markets), + title = stringResource(R.string.All_Markets), verticalScrollable = false, pop = pop ) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt index 33041421fa..9aee0a8402 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt @@ -80,7 +80,7 @@ class OpenPositionAdapter( val displaySymbol = position.tokenSymbol ?: context.getString(R.string.Unknown) titleTv.text = context.getString(R.string.Perpetual_Side_Symbol_Title, sideText, displaySymbol) leverageTv.isVisible = true - leverageTv.text = context.getString(R.string.Perpetual_Leverage_Format, position.leverage) + leverageTv.text = "${position.leverage}X" leverageTv.setTextColor(sideColor) leverageTv.setBackgroundResource( if (isLong) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt index 1721f8abd4..03debc9c82 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt @@ -1,5 +1,7 @@ package one.mixin.android.ui.home.web3.trade.perps +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -17,7 +19,6 @@ 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -29,7 +30,6 @@ import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.priceFormat -import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal @@ -48,6 +48,13 @@ fun OpenPositionItem( val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: stringResource(R.string.Unknown) val quantity = position.quantity.toBigDecimalOrNull()?.let { String.format("%f", it) } ?: position.quantity + val isLong = position.side.equals("long", true) + val sideColor = if (isLong) { + if (quoteColorPref) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + } else { + if (quoteColorPref) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + } + val leverageBackgroundColor = sideColor.copy(alpha = 0.1f) Row( modifier = Modifier @@ -73,7 +80,7 @@ fun OpenPositionItem( Column { Row(verticalAlignment = Alignment.CenterVertically) { - val sideText = if (position.side.equals("long", true)) { + val sideText = if (isLong) { stringResource(R.string.Long) } else { stringResource(R.string.Short) @@ -91,16 +98,14 @@ fun OpenPositionItem( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "${position.leverage}x", + text = "${position.leverage}X", fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist, + color = sideColor, + lineHeight = 14.sp, modifier = Modifier .clip(RoundedCornerShape(4.dp)) - .cardBackground( - MixinAppTheme.colors.backgroundGrayLight, - Color.Transparent - ) - .padding(horizontal = 3.dp, vertical = 1.dp) + .background(leverageBackgroundColor) + .padding(horizontal = 6.dp, vertical = 2.dp) ) } Spacer(modifier = Modifier.height(4.dp)) 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 c4349c9a06..015b1890b6 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 @@ -48,6 +48,7 @@ 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 kotlinx.coroutines.delay import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket @@ -58,12 +59,14 @@ import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.openUrl import one.mixin.android.extension.priceFormat import one.mixin.android.extension.putInt +import one.mixin.android.extension.toast import one.mixin.android.session.Session import one.mixin.android.ui.home.web3.trade.InputContent import one.mixin.android.ui.home.web3.trade.SwapActivity import one.mixin.android.ui.wallet.AddFeeBottomSheetDialogFragment import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.util.getMixinErrorStringByCode import one.mixin.android.vo.Fiats import one.mixin.android.vo.safe.TokenItem import java.math.BigDecimal @@ -71,20 +74,20 @@ import java.math.RoundingMode import kotlin.math.abs private fun getLeveragePrefKey(marketId: String) = "pref_perps_leverage_$marketId" +private const val MARKET_REFRESH_INTERVAL_MS = 5_000L @OptIn(ExperimentalMaterialApi::class) @Composable fun OpenPositionPage( - marketId: String, - marketSymbol: String, - displaySymbol: String, + market: PerpsMarket, isLong: Boolean, onBack: () -> Unit, - selectedToken: TokenItem? = null, + selectedToken: TokenItem?, onTokenSelect: () -> Unit = {}, ) { val context = LocalContext.current val viewModel = hiltViewModel() + val marketId = market.marketId val acceptedPerpAssetIds = remember { context.defaultSharedPreferences .getStringSet(Constants.Account.PREF_PERPS_ACCEPTED_ASSET_IDS, emptySet()) @@ -93,7 +96,7 @@ fun OpenPositionPage( .toSet() } - var market by remember { mutableStateOf(null) } + var currentMarket by remember(marketId) { mutableStateOf(market) } var currentToken by remember { mutableStateOf(selectedToken) } var availableTokens by remember { mutableStateOf>(emptyList()) } var usdtAmount by remember { mutableStateOf("") } @@ -102,14 +105,19 @@ fun OpenPositionPage( var leverage by remember { mutableFloatStateOf(savedLeverage.toFloat()) } LaunchedEffect(marketId) { - viewModel.loadMarketDetail( - marketId = marketId, - onSuccess = { data -> - market = data - }, - onError = {} - ) + while (true) { + viewModel.loadMarketDetail( + marketId = marketId, + onSuccess = { data -> + currentMarket = data + }, + onError = {} + ) + delay(MARKET_REFRESH_INTERVAL_MS) + } + } + LaunchedEffect(acceptedPerpAssetIds) { viewModel.loadUsdTokens { tokens -> val supportedTokens = tokens.filter { it.assetId in acceptedPerpAssetIds } availableTokens = supportedTokens @@ -125,13 +133,12 @@ fun OpenPositionPage( } } - val maxLeverage = market?.leverage ?: 100 + val maxLeverage = currentMarket.leverage val leverageOptions = generateLeverageOptions(maxLeverage) val quoteColorReversed = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed - val directionColor = if (isLong) risingColor else fallingColor val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() val inputAmount = usdtAmount.toBigDecimalOrNull() @@ -143,7 +150,6 @@ fun OpenPositionPage( MixinAppTheme { PageScaffold( title = stringResource(R.string.Open_Position), - subtitleText = stringResource(R.string.Perpetual), verticalScrollable = false, pop = onBack, actions = { @@ -168,31 +174,29 @@ fun OpenPositionPage( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - market?.let { m -> - CoilImage( - model = m.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop + CoilImage( + model = currentMarket.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${currentMarket.tokenSymbol}", + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = stringResource( + R.string.Current_price, + formatFiatPrice(currentMarket.markPrice, fiatRate, fiatSymbol) + ), + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Text( - text = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${m.tokenSymbol}", - fontSize = 16.sp, - color = MixinAppTheme.colors.textPrimary - ) - Text( - text = stringResource( - R.string.Current_price, - formatFiatPrice(m.markPrice, fiatRate, fiatSymbol) - ), - fontSize = 13.sp, - color = MixinAppTheme.colors.textAssist - ) - } } } @@ -277,6 +281,17 @@ fun OpenPositionPage( } ) } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = currentToken?.name + ?.takeIf { it.isNotBlank() } + ?: "", + style = TextStyle( + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + textAlign = TextAlign.End, + ), + ) } } Spacer(modifier = Modifier.height(2.dp)) @@ -429,7 +444,7 @@ fun OpenPositionPage( ) } Text( - text = "${calculateOrderValue(usdtAmount, leverage, market?.markPrice ?: "0")} ${market?.tokenSymbol}", + text = "${calculateOrderValue(usdtAmount, leverage, currentMarket.markPrice)} ${currentMarket.tokenSymbol}", fontSize = 14.sp, color = MixinAppTheme.colors.textAssist ) @@ -458,7 +473,7 @@ fun OpenPositionPage( } Text( text = calculateLiquidationPrice( - market?.markPrice ?: "0", + currentMarket.markPrice, leverage, isLong, fiatRate, @@ -485,7 +500,7 @@ fun OpenPositionPage( if (amount <= BigDecimal.ZERO) return@Button if (amount > (token.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO)) return@Button - val m = market ?: return@Button + val m = currentMarket val walletId = Session.getAccountId() ?: "" // Privacy Wallet if (walletId.isEmpty()) return@Button @@ -498,12 +513,12 @@ fun OpenPositionPage( viewModel.openPerpsOrder( assetId = token.assetId, - productId = marketId, + productId = m.marketId, side = if (isLong) "long" else "short", amount = orderValue.stripTrailingZeros().toPlainString(), leverage = leverage.toInt(), walletId = walletId, - marketSymbol = marketSymbol, + marketSymbol = m.symbol, entryPrice = m.markPrice, onSuccess = { response -> PerpsConfirmBottomSheetDialogFragment.newInstance( @@ -519,8 +534,13 @@ fun OpenPositionPage( onBack() }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) }, - onError = { error -> - // TODO: Show error toast or dialog + onError = { errorCode, errorMessage -> + val message = if (errorCode > 0) { + context.getMixinErrorStringByCode(errorCode, errorMessage) + } else { + errorMessage.ifBlank { context.getString(R.string.Data_error) } + } + toast(message) } ) }, 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 7c5740434c..a45ec39212 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 @@ -60,6 +60,7 @@ import one.mixin.android.ui.home.web3.trade.ClosedPositionItem import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal +import kotlin.math.abs private const val POSITION_REFRESH_INTERVAL_MS = 10_000L private const val CLOSED_POSITION_PREVIEW_LIMIT = 10 @@ -184,7 +185,7 @@ fun PerpetualContent( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = String.format("(%s%.2f%%)", if (totalPnlPercent >= 0) "+" else "", kotlin.math.abs(totalPnlPercent)), + text = String.format("(%s%.2f%%)", if (totalPnlPercent >= 0) "+" else "", abs(totalPnlPercent)), fontSize = 14.sp, color = if (totalPnl >= 0) risingColor else fallingColor, ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index a14020f728..319d3174c9 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -152,6 +152,7 @@ fun PerpetualGuidePage(pop: () -> Unit) { onSelect = { targetTab -> coroutineScope.launch { selectedTab = targetTab } }, + onClose = pop, ) Spacer(modifier = Modifier.height(24.dp)) } @@ -384,12 +385,36 @@ private fun GuideBottomNavigation( selectedTab: Int, tabs: List, onSelect: (Int) -> Unit, + onClose: () -> Unit, ) { val previousTab = (selectedTab - 1).takeIf { it >= 0 } val nextTab = (selectedTab + 1).takeIf { it < tabs.size } if (previousTab == null && nextTab == null) { return } + if (previousTab != null && nextTab == null) { + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GuideNavigationButton( + text = stringResource(R.string.Perpetual_Guide_Previous_Tab, tabs[previousTab]), + modifier = Modifier.weight(1f), + onClick = { onSelect(previousTab) }, + ) + GuideNavigationButton( + text = stringResource( + R.string.Perpetual_Guide_Next_Tab, + stringResource(R.string.Start) + ), + modifier = Modifier.weight(1f), + onClick = onClose, + ) + } + return + } if (previousTab != null && nextTab != null) { Row( modifier = Modifier 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 41351826e8..0bb4302ce6 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 @@ -236,7 +236,7 @@ class PerpetualViewModel @Inject constructor( marketSymbol: String, entryPrice: String, onSuccess: (OpenOrderResponse) -> Unit, - onError: (String) -> Unit + onError: (Int, String) -> Unit ) { viewModelScope.launch { try { @@ -287,14 +287,14 @@ class PerpetualViewModel @Inject constructor( onSuccess(data) } else { - val error = "Failed to open perps order: ${response.errorDescription}" - Timber.e(error) - onError(error) + val error = response.errorDescription + Timber.e("Failed to open perps order: code=${response.errorCode}, description=$error") + onError(response.errorCode, error) } } catch (e: Exception) { val error = "Error opening perps order: ${e.message}" Timber.e(e, error) - onError(error) + onError(-1, error) } } } 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 a8eb8fa386..9650983ce8 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 @@ -7,11 +7,18 @@ import androidx.activity.compose.setContent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.extension.toast import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshPerpsPositionsJob +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import one.mixin.android.session.Session +import one.mixin.android.R import one.mixin.android.ui.common.BaseActivity import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment import one.mixin.android.vo.safe.TokenItem @@ -22,6 +29,8 @@ class PerpsActivity : BaseActivity() { @Inject lateinit var jobManager: MixinJobManager + @Inject + lateinit var perpsMarketDao: PerpsMarketDao private var selectedToken by mutableStateOf(null) @@ -68,31 +77,40 @@ class PerpsActivity : BaseActivity() { refreshPositions() - setContent { - MixinAppTheme { - when (mode) { - MODE_OPEN_POSITION -> { + if (mode == MODE_OPEN_POSITION) { + lifecycleScope.launch { + val market = withContext(Dispatchers.IO) { + perpsMarketDao.getMarket(marketId) + } + if (market == null) { + toast(R.string.Alert_Not_Support) + finish() + return@launch + } + setContent { + MixinAppTheme { OpenPositionPage( - marketId = marketId, - marketSymbol = marketSymbol, - displaySymbol = displaySymbol, + market = market, isLong = isLong, onBack = { finish() }, selectedToken = selectedToken, onTokenSelect = { showTokenSelection() } ) } - - else -> { - PerpsMarketDetailPage( - marketId = marketId, - marketSymbol = marketSymbol, - displaySymbol = displaySymbol, - onBack = { finish() } - ) - } } } + return + } + + setContent { + MixinAppTheme { + PerpsMarketDetailPage( + marketId = marketId, + marketSymbol = marketSymbol, + displaySymbol = displaySymbol, + onBack = { finish() } + ) + } } } 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 b896cfadd6..b3e1632c4a 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 @@ -61,6 +61,7 @@ import one.mixin.android.extension.composeDp import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.priceFormat import one.mixin.android.extension.screenHeight import one.mixin.android.extension.withArgs import one.mixin.android.ui.common.BottomSheetViewModel @@ -71,6 +72,7 @@ import one.mixin.android.ui.wallet.ItemUserContent import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager import one.mixin.android.vo.User +import one.mixin.android.vo.Fiats import one.mixin.android.vo.safe.TokenItem import timber.log.Timber import java.math.BigDecimal @@ -175,6 +177,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() LaunchedEffect(Unit) { latestMarkPrice = markPrice @@ -363,6 +367,13 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } catch (e: Exception) { latestRoe } + val formattedPnlFiat = try { + val pnlFiat = pnl.multiply(fiatRate) + val sign = if (pnlFiat >= BigDecimal.ZERO) "+" else "-" + "$sign$fiatSymbol${pnlFiat.abs().priceFormat()}" + } catch (e: Exception) { + "${fiatSymbol}0" + } if (isLoading) { Box( @@ -437,7 +448,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen fontSize = 14.sp ) Text( - text = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} $settleAssetSymbol ($formattedRoe%)", + text = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} $settleAssetSymbol ($formattedRoe%, $formattedPnlFiat)", color = pnlColor, fontSize = 14.sp ) 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 f3e48cd232..71d248e1f3 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 @@ -530,7 +530,8 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm ) { val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO val profitPercent = 1.0 * leverage - val profitAmount = amountValue * BigDecimal(profitPercent / 100) + val profitAmount = amountValue * BigDecimal(profitPercent / 100) * fiatRate + val formattedProfitAmount = "${fiatSymbol}${profitAmount.priceFormat()}" Timber.d("ProfitLossInfo - amount: $amount, amountValue: $amountValue, leverage: $leverage, isLong: $isLong, profitPercent: $profitPercent, profitAmount: $profitAmount") @@ -543,14 +544,14 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm R.string.Price_Up_Profit, "1", String.format("%.1f", profitPercent), - String.format("%.2f", profitAmount) + formattedProfitAmount ) } else { stringResource( R.string.Price_Down_Profit, "1", String.format("%.1f", profitPercent), - String.format("%.2f", profitAmount) + formattedProfitAmount ) }, color = MixinAppTheme.colors.textAssist, 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 bf24d5767a..2fec138f27 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 @@ -374,12 +374,12 @@ private fun MarketInfoCard( .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .clickable { onLearnClick() } .padding(16.dp) ) { Row( modifier = Modifier - .fillMaxWidth() - .clickable { onLearnClick() }, + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Image(painter = painterResource(id = R.drawable.ic_perps_help), contentDescription = null) @@ -434,7 +434,7 @@ private fun MarketInfoCard( text = "${market.fundingRate}%", fontSize = 14.sp, fontWeight = FontWeight.Medium, - color = fundingColor + color = MixinAppTheme.colors.textPrimary ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt index 4131ea0ac5..1589371f03 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3.trade.perps +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -100,7 +101,7 @@ fun PerpsMarketItem( verticalAlignment = Alignment.CenterVertically ) { Text( - text = market.displaySymbol, + text = market.tokenSymbol, fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) @@ -109,13 +110,11 @@ fun PerpsMarketItem( text = "${market.leverage}x", fontSize = 12.sp, color = MixinAppTheme.colors.textAssist, + lineHeight = 14.sp, modifier = Modifier .clip(RoundedCornerShape(4.dp)) - .cardBackground( - MixinAppTheme.colors.backgroundGrayLight, - Color.Transparent - ) - .padding(horizontal = 3.dp, vertical = 1.dp) + .background(MixinAppTheme.colors.backgroundGrayLight,) + .padding(horizontal = 6.dp, vertical = 2.dp) ) } Spacer(modifier = Modifier.height(2.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt index 2b48b9d241..e5abedb8e0 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt @@ -50,7 +50,7 @@ class PerpsMarketListAdapter( val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() iconIv.loadImage(market.iconUrl, R.drawable.ic_avatar_place_holder) - symbolTv.text = market.displaySymbol + symbolTv.text = market.tokenSymbol val formattedVolume = try { BigDecimal(market.volume).multiply(fiatRate).numberFormatCompact() diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt index 0e78a32dc8..120afacf55 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt @@ -114,7 +114,7 @@ class PerpsMarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment( } private fun onMarketClick(market: PerpsMarket) { - PerpsActivity.Companion.showOpenPosition( + PerpsActivity.showOpenPosition( context = requireContext(), marketId = market.marketId, marketSymbol = market.symbol, 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 82ae47dab1..853bd0e46d 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 @@ -79,8 +79,9 @@ fun PositionDetailPage( val title = stringResource(R.string.Perpetual_Opened_Side_Title, sideText) val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO + val absQuantity = quantity.abs() val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO - val orderValue = quantity * markPrice + val orderValue = absQuantity * markPrice val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() @@ -233,7 +234,7 @@ fun PositionDetailPage( PositionDetailItem( label = stringResource(R.string.Order_Value).uppercase(), - value = "${String.format("%f", quantity)} ${position.tokenSymbol ?: ""}", + value = "${String.format("%f", absQuantity)} ${position.tokenSymbol ?: ""}", subtitle = formatFiat(orderValue) ) @@ -363,8 +364,9 @@ fun PositionDetailPage( val title = stringResource(R.string.Perpetual_Closed_Side_Title, sideText) val quantity = positionHistory.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO + val absQuantity = quantity.abs() val closePrice = positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO - val orderValue = quantity * closePrice + val orderValue = absQuantity * closePrice val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() @@ -510,7 +512,7 @@ fun PositionDetailPage( PositionDetailItem( label = stringResource(R.string.Order_Value).uppercase(), - value = "${String.format("%f", quantity)} ${positionHistory.tokenSymbol ?: ""}", + value = "${String.format("%f", absQuantity)} ${positionHistory.tokenSymbol ?: ""}", subtitle = formatFiat(orderValue) ) 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 a6c49a873f..a49e5a3100 100644 --- a/app/src/main/java/one/mixin/android/util/ErrorHandler.kt +++ b/app/src/main/java/one/mixin/android/util/ErrorHandler.kt @@ -224,6 +224,7 @@ 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 UNSUPPORTED_WATCH_ADDRESS = 10633 const val INVALID_REFERRAL_CODE = 10730 @@ -328,6 +329,9 @@ fun Context.getMixinErrorStringByCode( ErrorHandler.MAX_WALLET_REACHED -> { getString(R.string.error_too_many_wallets) } + ErrorHandler.PERPS_ORDER_VALUE_TOO_SMALL -> { + getString(R.string.error_perps_order_value_too_small) + } ErrorHandler.UNSUPPORTED_WATCH_ADDRESS -> { getString(R.string.error_watch_address_not_supported) } diff --git a/app/src/main/res/drawable/bg_perps_leverage_long.xml b/app/src/main/res/drawable/bg_perps_leverage_long.xml index a621d6a6d1..d4a7c09559 100644 --- a/app/src/main/res/drawable/bg_perps_leverage_long.xml +++ b/app/src/main/res/drawable/bg_perps_leverage_long.xml @@ -2,8 +2,5 @@ - - + diff --git a/app/src/main/res/drawable/bg_perps_leverage_short.xml b/app/src/main/res/drawable/bg_perps_leverage_short.xml index 66ae29a1ed..2f1c3adea3 100644 --- a/app/src/main/res/drawable/bg_perps_leverage_short.xml +++ b/app/src/main/res/drawable/bg_perps_leverage_short.xml @@ -2,8 +2,5 @@ - - + diff --git a/app/src/main/res/drawable/bg_perps_share_tag.xml b/app/src/main/res/drawable/bg_perps_share_tag.xml index f1d1239b8c..83a10c1ed2 100644 --- a/app/src/main/res/drawable/bg_perps_share_tag.xml +++ b/app/src/main/res/drawable/bg_perps_share_tag.xml @@ -1,9 +1,6 @@ - + - diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a7f64deb2d..1446f27ea6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -470,6 +470,7 @@ 错误 10614: 输入金额太小或太大,请重新输入。 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 + 错误 10650: 订单价值太小,请调整后重试。 错误 20114:验证码已过期 错误 20113:验证码错误 你已经尝试了超过 5 次,请等待 24 小时后再次尝试。 @@ -1685,6 +1686,7 @@ 暂无价格数据 余额 行情 + 市场 行情 查看全部 我的余额 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4872b9c6fb..d81a46fb5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -483,6 +483,7 @@ ERROR 10614: The input amount is either too small or too large, please adjust the amount. ERROR 10614: The amount exceeds the maximum allowable value of %1$s. Please adjust the amount. Try placing a limit order? No restrictions on amount or token. ERROR 10615: This trading pair is currently not supported, please try switching to a different token. + ERROR 10650: Order value is too small. ERROR 20114: Expired phone verification code ERROR 20113: Invalid phone verification code You have tried more than 5 times, please wait at least 24 hours to try again. @@ -1728,6 +1729,7 @@ Price data unavailable Balance Market + All Markets Markets View All My Balance From 38b5b54dc197f6c223e6f5f95c78595f02c2704c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 16:37:34 +0800 Subject: [PATCH 043/105] Support opening position --- .../mixin/android/db/perps/PerpsPositionDao.kt | 18 +++++++++--------- .../home/web3/trade/perps/OpenPositionPage.kt | 15 +-------------- .../home/web3/trade/perps/PerpetualContent.kt | 1 - .../web3/trade/perps/PerpetualGuidePage.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 6 files changed, 11 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index a16f82e9cf..ce629d91b2 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -22,7 +22,7 @@ interface PerpsPositionDao : BaseDao { SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p LEFT JOIN markets m ON m.market_id = p.product_id - WHERE p.wallet_id = :walletId AND p.state = 'open' + WHERE p.wallet_id = :walletId AND (p.state = 'open' or p.state = 'opening') ORDER BY p.created_at DESC """) suspend fun getOpenPositions(walletId: String): List @@ -32,7 +32,7 @@ interface PerpsPositionDao : BaseDao { SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p LEFT JOIN markets m ON m.market_id = p.product_id - WHERE p.wallet_id = :walletId AND p.state = 'open' + WHERE p.wallet_id = :walletId AND (p.state = 'open' or p.state = 'opening') ORDER BY p.created_at DESC """ ) @@ -42,7 +42,7 @@ interface PerpsPositionDao : BaseDao { SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p LEFT JOIN markets m ON m.market_id = p.product_id - WHERE p.wallet_id = :walletId AND p.state = 'open' + WHERE p.wallet_id = :walletId AND (p.state = 'open' or p.state = 'opening') ORDER BY p.created_at DESC """) fun getOpenPositionsPaged(walletId: String): DataSource.Factory @@ -69,22 +69,22 @@ interface PerpsPositionDao : BaseDao { @Query("DELETE FROM positions WHERE wallet_id = :walletId") suspend fun deleteByWallet(walletId: String) - @Query("DELETE FROM positions WHERE wallet_id = :walletId AND state = 'open'") + @Query("DELETE FROM positions WHERE wallet_id = :walletId AND (state = 'open' or state = 'opening')") suspend fun deleteOpenByWallet(walletId: String) - @Query("DELETE FROM positions WHERE wallet_id = :walletId AND state = 'open' AND position_id NOT IN (:positionIds)") + @Query("DELETE FROM positions WHERE wallet_id = :walletId AND (state = 'open' or state = 'opening') AND position_id NOT IN (:positionIds)") suspend fun deleteOpenByWalletAndNotIn(walletId: String, positionIds: List) - @Query("SELECT SUM(CAST(unrealized_pnl AS REAL)) FROM positions WHERE wallet_id = :walletId AND state = 'open'") + @Query("SELECT SUM(CAST(unrealized_pnl AS REAL)) FROM positions WHERE wallet_id = :walletId AND state = (state = 'open' or state = 'opening')") suspend fun getTotalUnrealizedPnl(walletId: String): Double? - @Query("SELECT COALESCE(SUM(CAST(unrealized_pnl AS REAL)), 0) FROM positions WHERE wallet_id = :walletId AND state = 'open'") + @Query("SELECT COALESCE(SUM(CAST(unrealized_pnl AS REAL)), 0) FROM positions WHERE wallet_id = :walletId AND (state = 'open' or state = 'opening')") fun observeTotalUnrealizedPnl(walletId: String): Flow - @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM positions WHERE wallet_id = :walletId AND state = 'open'") + @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM positions WHERE wallet_id = :walletId AND (state = 'open' or state = 'opening')") suspend fun getTotalOpenPositionValue(walletId: String): Double? - @Query("SELECT COALESCE(SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))), 0) FROM positions WHERE wallet_id = :walletId AND state = 'open'") + @Query("SELECT COALESCE(SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))), 0) FROM positions WHERE wallet_id = :walletId AND (state = 'open' or state = 'opening')") fun observeTotalOpenPositionValue(walletId: String): Flow @Query("DELETE FROM positions WHERE position_id = :positionId") 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 015b1890b6..9045514a84 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 @@ -408,7 +408,7 @@ fun OpenPositionPage( Text( text = profitInfo, fontSize = 13.sp, - color = MixinAppTheme.colors.textMinor, + color = MixinAppTheme.colors.textAssist, modifier = Modifier.padding(horizontal = 4.dp) ) @@ -457,19 +457,6 @@ fun OpenPositionPage( fontSize = 14.sp, color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - painter = painterResource(id = R.drawable.ic_tip), - contentDescription = null, - modifier = Modifier - .size(12.dp) - .clickable { - val activity = context as? FragmentActivity ?: return@clickable - PerpetualGuideFragment.newInstance() - .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) - }, - tint = MixinAppTheme.colors.textAssist - ) } Text( text = calculateLiquidationPrice( 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 a45ec39212..f1e4b49f90 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 @@ -183,7 +183,6 @@ fun PerpetualContent( fontSize = 14.sp, color = if (totalPnl >= 0) risingColor else fallingColor, ) - Spacer(modifier = Modifier.width(8.dp)) Text( text = String.format("(%s%.2f%%)", if (totalPnlPercent >= 0) "+" else "", abs(totalPnlPercent)), fontSize = 14.sp, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 319d3174c9..d8c63442bb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -347,7 +347,7 @@ private fun PositionContent() { value = "1,000 USDT" ), GuideRowData( - label = stringResource(R.string.Perpetual_Position_Value), + label = stringResource(R.string.Order_Value), value = "10,000 USDT (74.62 SOL)" ) ), diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1446f27ea6..d160db9211 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2257,7 +2257,6 @@ 价格上涨 %1$s%% → 亏损 -%2$s %3$s 杠杆倍数 投入资金 - 仓位价值 场景一:价格上涨 场景二:价格下跌 价格上涨 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d81a46fb5a..ceb226a902 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2316,7 +2316,6 @@ Price up %1$s%% → Loss -%2$s %3$s Leverage Investment - Position Value Scenario 1: Price Rise Scenario 2: Price Fall Price Rise From 3eaa5fdae0ada84b473699dbe761c0ab40551adc Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 16:51:51 +0800 Subject: [PATCH 044/105] feat(perps): add periodic position refresh and switch market detail to Flow --- .../home/web3/trade/perps/OpenPositionPage.kt | 97 ++++++++++++------- .../web3/trade/perps/PerpetualGuidePage.kt | 87 ++++++++++++++--- .../ui/home/web3/trade/perps/PerpsActivity.kt | 18 +++- .../web3/trade/perps/PerpsMarketDetailPage.kt | 48 ++++----- .../one/mixin/android/util/ErrorHandler.kt | 4 + app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 7 files changed, 185 insertions(+), 73 deletions(-) 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 9045514a84..2e81183073 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,6 +50,7 @@ import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket @@ -59,7 +61,6 @@ import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.openUrl import one.mixin.android.extension.priceFormat import one.mixin.android.extension.putInt -import one.mixin.android.extension.toast import one.mixin.android.session.Session import one.mixin.android.ui.home.web3.trade.InputContent import one.mixin.android.ui.home.web3.trade.SwapActivity @@ -100,6 +101,8 @@ fun OpenPositionPage( var currentToken by remember { mutableStateOf(selectedToken) } var availableTokens by remember { mutableStateOf>(emptyList()) } var usdtAmount by remember { mutableStateOf("") } + var errorInfo by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() val savedLeverage = context.defaultSharedPreferences.getInt(getLeveragePrefKey(marketId), 10) var leverage by remember { mutableFloatStateOf(savedLeverage.toFloat()) } @@ -132,13 +135,12 @@ fun OpenPositionPage( currentToken = availableTokens.firstOrNull { it.assetId == target.assetId } ?: target } } + LaunchedEffect(usdtAmount, leverage, currentToken?.assetId) { + errorInfo = null + } val maxLeverage = currentMarket.leverage val leverageOptions = generateLeverageOptions(maxLeverage) - val quoteColorReversed = context.defaultSharedPreferences - .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) - val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen - val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() val inputAmount = usdtAmount.toBigDecimalOrNull() @@ -146,6 +148,7 @@ fun OpenPositionPage( val hasInputAmount = inputAmount != null && inputAmount > BigDecimal.ZERO val insufficientBalance = hasInputAmount && inputAmount > tokenBalance val canReview = hasInputAmount && !insufficientBalance + val displayedErrorInfo = errorInfo?.takeIf { it.isNotBlank() } MixinAppTheme { PageScaffold( @@ -475,6 +478,18 @@ fun OpenPositionPage( Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(16.dp)) + if (displayedErrorInfo != null) { + Text( + text = displayedErrorInfo, + fontSize = 12.sp, + color = MixinAppTheme.colors.walletRed, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp), + textAlign = TextAlign.Center, + ) + } + Button( modifier = Modifier .padding(horizontal = 20.dp) @@ -497,39 +512,49 @@ fun OpenPositionPage( if (price == BigDecimal.ZERO) return@Button val orderValue = amount * BigDecimal(leverage.toDouble()) + errorInfo = null + + scope.launch { + val hasOpeningPosition = viewModel.getOpenPositionsFromDb(walletId) + .any { it.productId == m.marketId } + if (hasOpeningPosition) { + errorInfo = context.getString(R.string.error_waiting_other_orders) + return@launch + } - viewModel.openPerpsOrder( - assetId = token.assetId, - productId = m.marketId, - side = if (isLong) "long" else "short", - amount = orderValue.stripTrailingZeros().toPlainString(), - leverage = leverage.toInt(), - walletId = walletId, - marketSymbol = m.symbol, - entryPrice = m.markPrice, - onSuccess = { response -> - PerpsConfirmBottomSheetDialogFragment.newInstance( - marketSymbol = m.displaySymbol, - marketIcon = m.iconUrl, - isLong = isLong, - amount = response.payAmount ?: "", - leverage = leverage.toInt(), - entryPrice = m.markPrice, - tokenSymbol = token.symbol, - payUrl = response.payUrl - ).setOnDone { - onBack() - }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) - }, - onError = { errorCode, errorMessage -> - val message = if (errorCode > 0) { - context.getMixinErrorStringByCode(errorCode, errorMessage) - } else { - errorMessage.ifBlank { context.getString(R.string.Data_error) } + viewModel.openPerpsOrder( + assetId = token.assetId, + productId = m.marketId, + side = if (isLong) "long" else "short", + amount = orderValue.stripTrailingZeros().toPlainString(), + leverage = leverage.toInt(), + walletId = walletId, + marketSymbol = m.symbol, + entryPrice = m.markPrice, + onSuccess = { response -> + errorInfo = null + PerpsConfirmBottomSheetDialogFragment.newInstance( + marketSymbol = m.displaySymbol, + marketIcon = m.iconUrl, + isLong = isLong, + amount = response.payAmount ?: "", + leverage = leverage.toInt(), + entryPrice = m.markPrice, + tokenSymbol = token.symbol, + payUrl = response.payUrl + ).setOnDone { + onBack() + }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + }, + onError = { errorCode, errorMessage -> + errorInfo = if (errorCode > 0) { + context.getMixinErrorStringByCode(errorCode, errorMessage) + } else { + errorMessage.ifBlank { context.getString(R.string.Data_error) } + } } - toast(message) - } - ) + ) + } }, enabled = canReview, colors = ButtonDefaults.outlinedButtonColors( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index d8c63442bb..44ec85357d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -326,6 +327,13 @@ private fun LeverageContent() { @Composable private fun PositionContent() { + var isLongDirection by remember { mutableStateOf(true) } + val directionValue = if (isLongDirection) { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( @@ -336,7 +344,7 @@ private fun PositionContent() { ), GuideRowData( label = stringResource(R.string.Perpetual_Direction), - value = stringResource(R.string.Long) + value = directionValue ), GuideRowData( label = stringResource(R.string.Perpetual_Leverage_Times), @@ -358,7 +366,7 @@ private fun PositionContent() { initialPercent = 10, basePnlAmount = 1000, basePnlPercent = 100, - isProfit = true + isProfit = isLongDirection ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), @@ -366,9 +374,12 @@ private fun PositionContent() { initialPercent = 10, basePnlAmount = 1000, basePnlPercent = 100, - isProfit = false + isProfit = !isLongDirection ) - ) + ), + isDirectionSwitchEnabled = true, + isLongDirectionSelected = isLongDirection, + onDirectionSelected = { isLongDirection = it }, ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithInfoAndRiskCard( @@ -547,6 +558,9 @@ private fun ExampleWithScenariosCard( title: String, rows: List, scenarios: List, + isDirectionSwitchEnabled: Boolean = false, + isLongDirectionSelected: Boolean = true, + onDirectionSelected: ((Boolean) -> Unit)? = null, ) { val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences @@ -589,16 +603,39 @@ private fun ExampleWithScenariosCard( modifier = Modifier.weight(1f) ) if (label == directionLabel && (value == longDirection || value == shortDirection)) { - val directionColor = if (value == longDirection) risingColor else fallingColor - Text( - text = value, - fontSize = 14.sp, - color = Color.White, + if (isDirectionSwitchEnabled && onDirectionSelected != null) { + Row( modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .background(directionColor) - .padding(horizontal = 8.dp, vertical = 1.dp), - ) + .clip(RoundedCornerShape(8.dp)) + .background(MixinAppTheme.colors.backgroundWindow) + .padding(horizontal = 2.dp, vertical = 1.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + DirectionSwitchItem( + text = longDirection, + selected = isLongDirectionSelected, + selectedColor = risingColor, + onClick = { onDirectionSelected(true) }, + ) + DirectionSwitchItem( + text = shortDirection, + selected = !isLongDirectionSelected, + selectedColor = fallingColor, + onClick = { onDirectionSelected(false) }, + ) + } + } else { + val directionColor = if (value == longDirection) risingColor else fallingColor + Text( + text = value, + fontSize = 14.sp, + color = Color.White, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(directionColor) + .padding(horizontal = 8.dp, vertical = 1.dp), + ) + } } else { Row(verticalAlignment = Alignment.CenterVertically) { row.iconRes?.let { iconRes -> @@ -722,6 +759,30 @@ private fun ExampleWithScenariosCard( } } +@Composable +private fun DirectionSwitchItem( + text: String, + selected: Boolean, + selectedColor: Color, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(if (selected) selectedColor else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + fontSize = 12.sp, + fontWeight = FontWeight.W500, + color = if (selected) Color.White else MixinAppTheme.colors.textPrimary, + ) + } +} + private fun ScenarioData.formatPnl(currentPercent: Int): String { val safeInitialPercent = initialPercent.coerceAtLeast(1) val safeCurrentPercent = currentPercent.coerceIn(0, 10) 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 9650983ce8..28a62efeaa 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 @@ -7,14 +7,18 @@ import androidx.activity.compose.setContent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.extension.toast import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshPerpsPositionsJob +import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.session.Session @@ -40,6 +44,7 @@ class PerpsActivity : BaseActivity() { private const val EXTRA_MARKET_DISPLAY_SYMBOL = "extra_market_display_symbol" private const val EXTRA_MODE = "extra_mode" private const val EXTRA_IS_LONG = "extra_is_long" + private const val POSITION_REFRESH_INTERVAL_MS = 10_000L const val MODE_DETAIL = "detail" const val MODE_OPEN_POSITION = "open_position" @@ -75,7 +80,7 @@ class PerpsActivity : BaseActivity() { val mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_DETAIL val isLong = intent.getBooleanExtra(EXTRA_IS_LONG, true) - refreshPositions() + observePositionRefresh() if (mode == MODE_OPEN_POSITION) { lifecycleScope.launch { @@ -129,4 +134,15 @@ class PerpsActivity : BaseActivity() { jobManager.addJobInBackground(RefreshPerpsPositionsJob(it)) } } + + private fun observePositionRefresh() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + refreshPositions() + delay(POSITION_REFRESH_INTERVAL_MS) + } + } + } + } } 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 2fec138f27..3201c0d0ec 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 @@ -31,7 +31,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,7 +45,8 @@ 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 kotlinx.coroutines.launch +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.flowOf import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket @@ -66,6 +66,8 @@ import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal +private const val CLOSED_POSITION_PREVIEW_LIMIT = 100 + @Composable fun PerpsMarketDetailPage( marketId: String, @@ -78,9 +80,23 @@ fun PerpsMarketDetailPage( var market by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var selectedTimeFrame by remember { mutableIntStateOf(0) } - var currentPosition by remember { mutableStateOf(null) } - var closedPositions by remember { mutableStateOf>(emptyList()) } - val coroutineScope = rememberCoroutineScope() + val walletId = Session.getAccountId().orEmpty() + val openPositions by remember(walletId) { + if (walletId.isNotEmpty()) { + viewModel.observeOpenPositions(walletId) + } else { + flowOf(emptyList()) + } + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val allClosedPositions by remember(walletId) { + if (walletId.isNotEmpty()) { + viewModel.observeClosedPositions(walletId, CLOSED_POSITION_PREVIEW_LIMIT) + } else { + flowOf(emptyList()) + } + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val currentPosition = openPositions.firstOrNull { it.productId == marketId } + val closedPositions = allClosedPositions.filter { it.productId == marketId } val timeFrameValues = listOf("1h", "1d", "1w", "1M") val timeFrameLabels = listOf( @@ -94,8 +110,6 @@ fun PerpsMarketDetailPage( val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed - val walletId = Session.getAccountId() ?: "" - LaunchedEffect(marketId) { viewModel.loadMarketDetail( marketId = marketId, @@ -107,16 +121,6 @@ fun PerpsMarketDetailPage( isLoading = false } ) - - if (walletId.isNotEmpty()) { - viewModel.getPositionByMarket(walletId, marketId) { position -> - currentPosition = position - } - - viewModel.getClosedPositionsByMarket(walletId, marketId) { positions -> - closedPositions = positions - } - } } PageScaffold( @@ -162,7 +166,7 @@ fun PerpsMarketDetailPage( timeFrameValues = timeFrameValues, timeFrameLabels = timeFrameLabels, onTimeFrameChange = { index -> - coroutineScope.launch { selectedTimeFrame = index } + selectedTimeFrame = index } ) } else if (isLoading) { @@ -183,7 +187,7 @@ fun PerpsMarketDetailPage( Spacer(modifier = Modifier.height(16.dp)) if (currentPosition != null) { - OpenPositionCard(position = currentPosition!!) + OpenPositionCard(position = currentPosition) Spacer(modifier = Modifier.height(16.dp)) } @@ -248,13 +252,11 @@ fun PerpsMarketDetailPage( .height(48.dp), onClick = { val activity = context as? FragmentActivity ?: return@Button - val position = currentPosition?.toPosition() ?: return@Button + val position = currentPosition.toPosition() PerpsCloseBottomSheetDialogFragment.newInstance( position = position, - ).setOnDone { - currentPosition = null - }.show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + ).show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) }, colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MixinAppTheme.colors.accent 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 a49e5a3100..f89e0bbd13 100644 --- a/app/src/main/java/one/mixin/android/util/ErrorHandler.kt +++ b/app/src/main/java/one/mixin/android/util/ErrorHandler.kt @@ -225,6 +225,7 @@ open class ErrorHandler { const val SIMULATE_TRANSACTION_FAILED = 10631 const val MAX_WALLET_REACHED = 10632 const val PERPS_ORDER_VALUE_TOO_SMALL = 10650 + const val PERPS_MARKET_ALREADY_HAS_ACTIVE_POSITION = 10651 const val UNSUPPORTED_WATCH_ADDRESS = 10633 const val INVALID_REFERRAL_CODE = 10730 @@ -332,6 +333,9 @@ fun Context.getMixinErrorStringByCode( ErrorHandler.PERPS_ORDER_VALUE_TOO_SMALL -> { getString(R.string.error_perps_order_value_too_small) } + ErrorHandler.PERPS_MARKET_ALREADY_HAS_ACTIVE_POSITION -> { + getString(R.string.error_perps_market_already_has_active_position) + } ErrorHandler.UNSUPPORTED_WATCH_ADDRESS -> { getString(R.string.error_watch_address_not_supported) } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d160db9211..0ab94a2d2f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -471,6 +471,8 @@ 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 错误 10650: 订单价值太小,请调整后重试。 + 错误 10651: 当前市场已有活跃仓位。 + 等待其他订单完成 错误 20114:验证码已过期 错误 20113:验证码错误 你已经尝试了超过 5 次,请等待 24 小时后再次尝试。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ceb226a902..e7c21e12f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -484,6 +484,8 @@ 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 10650: Order value is too small. + ERROR 10651: Market already has an active position. + Wait for other orders to complete. ERROR 20114: Expired phone verification code ERROR 20113: Invalid phone verification code You have tried more than 5 times, please wait at least 24 hours to try again. From 896f02abe0fc669e45f94f0ea9d22b65305ef1db Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 18:54:42 +0800 Subject: [PATCH 045/105] feat(perps): polish trading flow, data refresh, and share UX --- .../java/one/mixin/android/db/TokenDao.kt | 4 + .../link/LinkBottomSheetDialogFragment.kt | 3 +- .../ui/home/web3/trade/ClosedPositionItem.kt | 5 +- .../ui/home/web3/trade/TradeFragment.kt | 8 +- .../trade/perps/AllPerpsMarketsFragment.kt | 3 +- .../web3/trade/perps/AllPositionsFragment.kt | 7 + .../web3/trade/perps/OpenPositionAdapter.kt | 2 +- .../home/web3/trade/perps/OpenPositionItem.kt | 6 +- .../web3/trade/perps/PerpetualGuidePage.kt | 223 +++++++++++------- .../web3/trade/perps/PerpetualViewModel.kt | 4 + .../ui/home/web3/trade/perps/PerpsActivity.kt | 22 +- .../PerpsCloseBottomSheetDialogFragment.kt | 11 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 78 +++--- .../home/web3/trade/perps/PerpsMarketItem.kt | 4 +- ...erpsMarketListBottomSheetDialogFragment.kt | 1 + .../trade/perps/PerpsPositionShareActivity.kt | 22 +- .../trade/perps/PositionDetailFragment.kt | 9 +- .../web3/trade/perps/PositionDetailPage.kt | 10 +- .../android/ui/wallet/DepositShareActivity.kt | 5 +- .../android/ui/wallet/MarketShareActivity.kt | 15 +- .../res/layout/activity_deposit_share.xml | 6 +- .../main/res/layout/activity_market_share.xml | 6 +- .../layout/activity_perps_position_share.xml | 16 +- app/src/main/res/layout/item_market_list.xml | 3 - app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 26 files changed, 301 insertions(+), 176 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/TokenDao.kt b/app/src/main/java/one/mixin/android/db/TokenDao.kt index e20aa3d4dd..130c3513fc 100644 --- a/app/src/main/java/one/mixin/android/db/TokenDao.kt +++ b/app/src/main/java/one/mixin/android/db/TokenDao.kt @@ -135,6 +135,10 @@ interface TokenDao : BaseDao { @Query("$PREFIX_ASSET_ITEM WHERE a1.asset_id = :assetId") fun assetItemFlow(assetId: String): Flow + @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) + @Query("$PREFIX_ASSET_ITEM WHERE a1.chain_id = :chainId AND a1.symbol = :symbol COLLATE NOCASE LIMIT 1") + fun assetItemFlowByChainAndSymbol(chainId: String, symbol: String): Flow + @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("$PREFIX_ASSET_ITEM WHERE ae.balance > 0 AND (ae.hidden IS NULL OR NOT ae.hidden) $POSTFIX_ASSET_ITEM") fun assetItemsWithBalance(defaultIconUrl: String = Constants.DEFAULT_ICON_URL): LiveData> 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 55a4194271..6732c266ee 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 @@ -1096,7 +1096,8 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { requireContext(), market.marketId, market.symbol, - market.displaySymbol + market.displaySymbol, + market.tokenSymbol ) dismiss() return diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index caeae7d9f1..8fba88dfeb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -32,6 +32,7 @@ import one.mixin.android.extension.priceFormat import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal +import kotlin.math.abs @Composable fun ClosedPositionItem( @@ -65,7 +66,7 @@ fun ClosedPositionItem( } } - val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: "Unknown" + val displaySymbol = position.tokenSymbol ?: "Unknown" val quantity = try { val qty = BigDecimal(position.quantity) String.format("%f", qty) @@ -118,7 +119,7 @@ fun ClosedPositionItem( } Spacer(modifier = Modifier.height(4.dp)) Text( - text = "$quantity ${position.tokenSymbol ?: ""}", + text = "${(quantity.toBigDecimalOrNull()?: BigDecimal.ZERO).abs().stripTrailingZeros().toPlainString()} ${position.tokenSymbol ?: ""}", fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) 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 681080a1e0..b6b85c2e0c 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 @@ -366,7 +366,13 @@ class TradeFragment : BaseFragment() { navTo(PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) }, onMarketItemClick = { market -> - PerpsActivity.showDetail(requireContext(), market.marketId, market.symbol, market.displaySymbol) + PerpsActivity.showDetail( + requireContext(), + market.marketId, + market.symbol, + market.displaySymbol, + market.tokenSymbol + ) }, onClosedPositionClick = { position -> navTo(PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt index d3e4406dc1..5768819b07 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt @@ -79,7 +79,8 @@ class AllPerpsMarketsFragment : BaseFragment() { requireContext(), market.marketId, market.symbol, - market.displaySymbol + market.displaySymbol, + market.tokenSymbol ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index ab7d394944..9f5f08ec60 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -24,6 +24,7 @@ import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.databinding.FragmentAllClosedPositionsBinding import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.openUrl import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseFragment import one.mixin.android.ui.home.web3.trade.ClosedPositionAdapter @@ -123,6 +124,12 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions titleView.leftIb.setOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() } + titleView.rightIb.setImageResource(R.drawable.ic_support) + titleView.rightAnimator.visibility = View.VISIBLE + titleView.rightAnimator.displayedChild = 0 + titleView.rightAnimator.setOnClickListener { + context?.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + } titleView.setSubTitle(getString(R.string.Closed_Positions), "") positionsRv.layoutManager = LinearLayoutManager(requireContext()) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt index 9aee0a8402..e130ac8b7e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt @@ -80,7 +80,7 @@ class OpenPositionAdapter( val displaySymbol = position.tokenSymbol ?: context.getString(R.string.Unknown) titleTv.text = context.getString(R.string.Perpetual_Side_Symbol_Title, sideText, displaySymbol) leverageTv.isVisible = true - leverageTv.text = "${position.leverage}X" + leverageTv.text = "${position.leverage}x" leverageTv.setTextColor(sideColor) leverageTv.setBackgroundResource( if (isLong) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt index 03debc9c82..0d7e773ebc 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt @@ -46,7 +46,7 @@ fun OpenPositionItem( val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() - val displaySymbol = position.displaySymbol ?: position.tokenSymbol ?: stringResource(R.string.Unknown) + val displaySymbol = position.tokenSymbol ?: stringResource(R.string.Unknown) val quantity = position.quantity.toBigDecimalOrNull()?.let { String.format("%f", it) } ?: position.quantity val isLong = position.side.equals("long", true) val sideColor = if (isLong) { @@ -98,14 +98,14 @@ fun OpenPositionItem( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "${position.leverage}X", + text = "${position.leverage}x", fontSize = 12.sp, color = sideColor, lineHeight = 14.sp, modifier = Modifier .clip(RoundedCornerShape(4.dp)) .background(leverageBackgroundColor) - .padding(horizontal = 6.dp, vertical = 2.dp) + .padding(horizontal = 3.dp, vertical = 2.dp) ) } Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 44ec85357d..5e8407f8f1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -42,7 +41,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.math.RoundingMode import kotlin.math.roundToInt import one.mixin.android.Constants import one.mixin.android.R @@ -320,19 +323,31 @@ private fun LeverageContent() { DescriptionWithInfoAndRiskCard( description = stringResource(R.string.Perpetual_Leverage_Desc), infoTitle = stringResource(R.string.Perpetual_PnL_Impact), - infoContent = stringResource(R.string.Perpetual_Leverage_Impact), + infoContents = listOf(stringResource(R.string.Perpetual_Leverage_Impact)), riskContent = stringResource(R.string.Perpetual_Leverage_Risk) ) } @Composable private fun PositionContent() { - var isLongDirection by remember { mutableStateOf(true) } - val directionValue = if (isLongDirection) { - stringResource(R.string.Long) - } else { - stringResource(R.string.Short) - } + val viewModel = hiltViewModel() + var leverage by remember { mutableIntStateOf(10) } + var investment by remember { mutableIntStateOf(1000) } + val solToken by remember { + viewModel.observeTokenByChainAndSymbol( + chainId = Constants.ChainId.Solana, + symbol = "SOL", + ) + }.collectAsStateWithLifecycle(initialValue = null) + val localSolPrice = solToken?.priceUsd?.toBigDecimalOrNull() + + val orderValueUsdt = leverage * investment + val orderValueText = buildOrderValueText( + orderValueUsdt = orderValueUsdt, + localSolPrice = localSolPrice + ) + val basePnlAmount = orderValueUsdt / 10 + val basePnlPercent = leverage * 10 ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), @@ -344,19 +359,19 @@ private fun PositionContent() { ), GuideRowData( label = stringResource(R.string.Perpetual_Direction), - value = directionValue + value = stringResource(R.string.Long) ), GuideRowData( label = stringResource(R.string.Perpetual_Leverage_Times), - value = "10x" + value = "${leverage}x" ), GuideRowData( label = stringResource(R.string.Perpetual_Investment), - value = "1,000 USDT" + value = "${formatGuideInt(investment)} USDT" ), GuideRowData( label = stringResource(R.string.Order_Value), - value = "10,000 USDT (74.62 SOL)" + value = orderValueText ) ), scenarios = listOf( @@ -364,28 +379,32 @@ private fun PositionContent() { scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), initialPercent = 10, - basePnlAmount = 1000, - basePnlPercent = 100, - isProfit = isLongDirection + basePnlAmount = basePnlAmount, + basePnlPercent = basePnlPercent, + isProfit = true ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), initialPercent = 10, - basePnlAmount = 1000, - basePnlPercent = 100, - isProfit = !isLongDirection + basePnlAmount = basePnlAmount, + basePnlPercent = basePnlPercent, + isProfit = false ) ), - isDirectionSwitchEnabled = true, - isLongDirectionSelected = isLongDirection, - onDirectionSelected = { isLongDirection = it }, + leverageValue = leverage, + onLeverageChange = { leverage = it.coerceIn(0, 100) }, + investmentValue = investment, + onInvestmentChange = { investment = it.coerceIn(10, 1000) }, ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithInfoAndRiskCard( description = stringResource(R.string.Perpetual_Position_Desc), infoTitle = stringResource(R.string.Perpetual_Position_Usage), - infoContent = stringResource(R.string.Perpetual_Position_Usage_Desc), + infoContents = listOf( + stringResource(R.string.Perpetual_Position_Usage_Support_Current_Position), + stringResource(R.string.Perpetual_Position_Usage_Offset_Floating_Losses), + ), riskContent = stringResource(R.string.Perpetual_Position_Risk) ) } @@ -558,9 +577,10 @@ private fun ExampleWithScenariosCard( title: String, rows: List, scenarios: List, - isDirectionSwitchEnabled: Boolean = false, - isLongDirectionSelected: Boolean = true, - onDirectionSelected: ((Boolean) -> Unit)? = null, + leverageValue: Int? = null, + onLeverageChange: ((Int) -> Unit)? = null, + investmentValue: Int? = null, + onInvestmentChange: ((Int) -> Unit)? = null, ) { val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences @@ -573,6 +593,8 @@ private fun ExampleWithScenariosCard( } } val directionLabel = stringResource(R.string.Perpetual_Direction) + val leverageLabel = stringResource(R.string.Perpetual_Leverage_Times) + val investmentLabel = stringResource(R.string.Perpetual_Investment) val longDirection = stringResource(R.string.Long) val shortDirection = stringResource(R.string.Short) Column( @@ -603,39 +625,32 @@ private fun ExampleWithScenariosCard( modifier = Modifier.weight(1f) ) if (label == directionLabel && (value == longDirection || value == shortDirection)) { - if (isDirectionSwitchEnabled && onDirectionSelected != null) { - Row( + val directionColor = if (value == longDirection) risingColor else fallingColor + Text( + text = value, + fontSize = 14.sp, + color = Color.White, modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MixinAppTheme.colors.backgroundWindow) - .padding(horizontal = 2.dp, vertical = 1.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - DirectionSwitchItem( - text = longDirection, - selected = isLongDirectionSelected, - selectedColor = risingColor, - onClick = { onDirectionSelected(true) }, - ) - DirectionSwitchItem( - text = shortDirection, - selected = !isLongDirectionSelected, - selectedColor = fallingColor, - onClick = { onDirectionSelected(false) }, - ) - } - } else { - val directionColor = if (value == longDirection) risingColor else fallingColor - Text( - text = value, - fontSize = 14.sp, - color = Color.White, - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .background(directionColor) - .padding(horizontal = 8.dp, vertical = 1.dp), - ) - } + .clip(RoundedCornerShape(6.dp)) + .background(directionColor) + .padding(horizontal = 8.dp, vertical = 1.dp), + ) + } else if (label == leverageLabel && leverageValue != null && onLeverageChange != null) { + GuideNumberAdjuster( + valueText = "${leverageValue}x", + canDecrease = leverageValue > 0, + canIncrease = leverageValue < 100, + onDecrease = { onLeverageChange((leverageValue - 1).coerceAtLeast(0)) }, + onIncrease = { onLeverageChange((leverageValue + 1).coerceAtMost(100)) }, + ) + } else if (label == investmentLabel && investmentValue != null && onInvestmentChange != null) { + GuideNumberAdjuster( + valueText = "${formatGuideInt(investmentValue)} USDT", + canDecrease = investmentValue > 10, + canIncrease = investmentValue < 1000, + onDecrease = { onInvestmentChange((investmentValue - 10).coerceAtLeast(10)) }, + onIncrease = { onInvestmentChange((investmentValue + 10).coerceAtMost(1000)) }, + ) } else { Row(verticalAlignment = Alignment.CenterVertically) { row.iconRes?.let { iconRes -> @@ -760,29 +775,78 @@ private fun ExampleWithScenariosCard( } @Composable -private fun DirectionSwitchItem( - text: String, - selected: Boolean, - selectedColor: Color, - onClick: () -> Unit, +private fun GuideNumberAdjuster( + valueText: String, + canDecrease: Boolean, + canIncrease: Boolean, + onDecrease: () -> Unit, + onIncrease: () -> Unit, ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .background(if (selected) selectedColor else Color.Transparent) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp), - contentAlignment = Alignment.Center, + Row( + verticalAlignment = Alignment.CenterVertically, ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow) + .alpha(if (canDecrease) 1f else 0.5f) + .clickable(enabled = canDecrease, onClick = onDecrease), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_minus), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp), + ) + } Text( - text = text, - fontSize = 12.sp, + text = valueText, + fontSize = 14.sp, fontWeight = FontWeight.W500, - color = if (selected) Color.White else MixinAppTheme.colors.textPrimary, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.padding(horizontal = 8.dp), ) + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow) + .alpha(if (canIncrease) 1f else 0.5f) + .clickable(enabled = canIncrease, onClick = onIncrease), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_add), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp), + ) + } } } +private fun formatGuideInt(value: Int): String { + return String.format("%,d", value) +} + +private fun buildOrderValueText( + orderValueUsdt: Int, + localSolPrice: BigDecimal?, +): String { + val usdtText = "${formatGuideInt(orderValueUsdt)} USDT" + val solPrice = localSolPrice ?: return "$usdtText (-- SOL)" + if (solPrice <= BigDecimal.ZERO) { + return "$usdtText (-- SOL)" + } + val solAmount = BigDecimal(orderValueUsdt.toString()) + .divide(solPrice, 2, RoundingMode.HALF_UP) + .stripTrailingZeros() + .toPlainString() + return "$usdtText ($solAmount SOL)" +} + private fun ScenarioData.formatPnl(currentPercent: Int): String { val safeInitialPercent = initialPercent.coerceAtLeast(1) val safeCurrentPercent = currentPercent.coerceIn(0, 10) @@ -842,7 +906,7 @@ private fun DescriptionWithRulesCard( private fun DescriptionWithInfoAndRiskCard( description: String, infoTitle: String, - infoContent: String, + infoContents: List, riskContent: String, ) { Column( @@ -878,12 +942,13 @@ private fun DescriptionWithInfoAndRiskCard( color = MixinAppTheme.colors.textMinor ) Spacer(modifier = Modifier.height(6.dp)) - Text( - text = infoContent, - fontSize = 14.sp, - lineHeight = 18.sp, - color = MixinAppTheme.colors.textPrimary - ) + infoContents.forEach { content -> + DotText( + text = content, + modifier = Modifier.padding(vertical = 2.dp), + color = MixinAppTheme.colors.textPrimary, + ) + } } Spacer(modifier = Modifier.height(12.dp)) 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 0bb4302ce6..1257be3864 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 @@ -321,6 +321,10 @@ class PerpetualViewModel @Inject constructor( return perpsPositionDao.observePosition(positionId) } + fun observeTokenByChainAndSymbol(chainId: String, symbol: String): Flow { + return tokenDao.assetItemFlowByChainAndSymbol(chainId, symbol) + } + fun refreshSinglePosition(positionId: String, walletId: String? = null) { viewModelScope.launch { try { 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 28a62efeaa..0262aa734e 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 @@ -42,6 +42,7 @@ class PerpsActivity : BaseActivity() { private const val EXTRA_MARKET_ID = "extra_market_id" private const val EXTRA_MARKET_SYMBOL = "extra_market_symbol" private const val EXTRA_MARKET_DISPLAY_SYMBOL = "extra_market_display_symbol" + private const val EXTRA_MARKET_TOKEN_SYMBOL = "extra_market_token_symbol" private const val EXTRA_MODE = "extra_mode" private const val EXTRA_IS_LONG = "extra_is_long" private const val POSITION_REFRESH_INTERVAL_MS = 10_000L @@ -49,21 +50,36 @@ class PerpsActivity : BaseActivity() { const val MODE_DETAIL = "detail" const val MODE_OPEN_POSITION = "open_position" - fun showDetail(context: Context, marketId: String, marketSymbol: String, marketDisplaySymbol: String) { + fun showDetail( + context: Context, + marketId: String, + marketSymbol: String, + marketDisplaySymbol: String, + marketTokenSymbol: String = "", + ) { val intent = Intent(context, PerpsActivity::class.java).apply { putExtra(EXTRA_MARKET_ID, marketId) putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) putExtra(EXTRA_MARKET_DISPLAY_SYMBOL, marketDisplaySymbol) + putExtra(EXTRA_MARKET_TOKEN_SYMBOL, marketTokenSymbol) putExtra(EXTRA_MODE, MODE_DETAIL) } context.startActivity(intent) } - fun showOpenPosition(context: Context, marketId: String, marketSymbol: String, marketDisplaySymbol: String, isLong: Boolean) { + fun showOpenPosition( + context: Context, + marketId: String, + marketSymbol: String, + marketDisplaySymbol: String, + marketTokenSymbol: String = "", + isLong: Boolean, + ) { val intent = Intent(context, PerpsActivity::class.java).apply { putExtra(EXTRA_MARKET_ID, marketId) putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) putExtra(EXTRA_MARKET_DISPLAY_SYMBOL, marketDisplaySymbol) + putExtra(EXTRA_MARKET_TOKEN_SYMBOL, marketTokenSymbol) putExtra(EXTRA_MODE, MODE_OPEN_POSITION) putExtra(EXTRA_IS_LONG, isLong) } @@ -77,6 +93,7 @@ class PerpsActivity : BaseActivity() { val marketId = intent.getStringExtra(EXTRA_MARKET_ID) ?: "" val marketSymbol = intent.getStringExtra(EXTRA_MARKET_SYMBOL) ?: "" val displaySymbol = intent.getStringExtra(EXTRA_MARKET_DISPLAY_SYMBOL) ?: "" + val tokenSymbol = intent.getStringExtra(EXTRA_MARKET_TOKEN_SYMBOL) ?: "" val mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_DETAIL val isLong = intent.getBooleanExtra(EXTRA_IS_LONG, true) @@ -113,6 +130,7 @@ class PerpsActivity : BaseActivity() { marketId = marketId, marketSymbol = marketSymbol, displaySymbol = displaySymbol, + tokenSymbol = tokenSymbol, onBack = { finish() } ) } 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 b3e1632c4a..c7959988dd 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 @@ -425,6 +425,15 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen ) Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { + CoilImage( + model = asset.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(18.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(6.dp)) Text( text = "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", color = MixinAppTheme.colors.textPrimary, @@ -448,7 +457,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen fontSize = 14.sp ) Text( - text = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} $settleAssetSymbol ($formattedRoe%, $formattedPnlFiat)", + text = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} $settleAssetSymbol ($formattedRoe%)", color = pnlColor, fontSize = 14.sp ) 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 3201c0d0ec..b5c2e7d604 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 @@ -73,6 +73,7 @@ fun PerpsMarketDetailPage( marketId: String, marketSymbol: String, displaySymbol: String, + tokenSymbol: String, onBack: () -> Unit, ) { val context = LocalContext.current @@ -162,6 +163,7 @@ fun PerpsMarketDetailPage( market = market!!, marketSymbol = marketSymbol, displaySymbol = displaySymbol, + tokenSymbol = tokenSymbol, selectedTimeFrame = selectedTimeFrame, timeFrameValues = timeFrameValues, timeFrameLabels = timeFrameLabels, @@ -291,6 +293,7 @@ fun PerpsMarketDetailPage( marketId = marketId, marketSymbol = marketSymbol, marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, + marketTokenSymbol = market?.tokenSymbol ?: "", isLong = true ) }, @@ -323,6 +326,7 @@ fun PerpsMarketDetailPage( marketId = marketId, marketSymbol = marketSymbol, marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, + marketTokenSymbol = market?.tokenSymbol ?: "", isLong = false ) }, @@ -460,6 +464,7 @@ private fun MarketDetailCard( market: PerpsMarket, marketSymbol: String, displaySymbol: String, + tokenSymbol: String, selectedTimeFrame: Int, timeFrameValues: List, timeFrameLabels: List, @@ -482,6 +487,10 @@ private fun MarketDetailCard( val isPositive = change >= BigDecimal.ZERO val changeColor = if (isPositive) risingColor else fallingColor val changeText = "${if (isPositive) "+" else ""}${market.change}%" + val displayTokenSymbol = tokenSymbol + .takeIf { it.isNotBlank() } + ?: market.tokenSymbol.takeIf { it.isNotBlank() } + ?: displaySymbol val formattedPrice = try { val price = BigDecimal(market.markPrice).multiply(fiatRate) @@ -498,19 +507,18 @@ private fun MarketDetailCard( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = displaySymbol, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, + text = displayTokenSymbol, + fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(7.dp)) Text( text = "${fiatSymbol}$formattedPrice", - fontSize = 24.sp, - fontWeight = FontWeight.Bold, + fontSize = 22.sp, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(8.dp)) Text( text = changeText, fontSize = 14.sp, @@ -680,7 +688,7 @@ private fun OpenPositionCard( fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.width(4.dp)) + /* Spacer(modifier = Modifier.width(4.dp)) Icon( painter = painterResource(id = R.drawable.ic_tip), contentDescription = null, @@ -692,7 +700,7 @@ private fun OpenPositionCard( .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) }, tint = MixinAppTheme.colors.textAssist - ) + )*/ } Text( text = "${quantity.stripTrailingZeros().toPlainString()} ${position.tokenSymbol}", @@ -709,19 +717,19 @@ private fun OpenPositionCard( color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - painter = painterResource(id = R.drawable.ic_tip), - contentDescription = null, - modifier = Modifier - .size(12.dp) - .clickable { - val activity = context as? FragmentActivity ?: return@clickable - PerpetualGuideFragment.newInstance() - .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) - }, - tint = MixinAppTheme.colors.textAssist - ) +// Spacer(modifier = Modifier.width(4.dp)) +// Icon( +// painter = painterResource(id = R.drawable.ic_tip), +// contentDescription = null, +// modifier = Modifier +// .size(12.dp) +// .clickable { +// val activity = context as? FragmentActivity ?: return@clickable +// PerpetualGuideFragment.newInstance() +// .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) +// }, +// tint = MixinAppTheme.colors.textAssist +// ) } Text( text = "${fiatSymbol}${orderValue.priceFormat()}", @@ -758,19 +766,19 @@ private fun OpenPositionCard( fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - painter = painterResource(id = R.drawable.ic_tip), - contentDescription = null, - modifier = Modifier - .size(12.dp) - .clickable { - val activity = context as? FragmentActivity ?: return@clickable - PerpetualGuideFragment.newInstance() - .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) - }, - tint = MixinAppTheme.colors.textAssist - ) +// Spacer(modifier = Modifier.width(4.dp)) +// Icon( +// painter = painterResource(id = R.drawable.ic_tip), +// contentDescription = null, +// modifier = Modifier +// .size(12.dp) +// .clickable { +// val activity = context as? FragmentActivity ?: return@clickable +// PerpetualGuideFragment.newInstance() +// .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) +// }, +// tint = MixinAppTheme.colors.textAssist +// ) } Text( text = "${fiatSymbol}${liquidationPrice.multiply(fiatRate).priceFormat()}", diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt index 1589371f03..6d8e8d8642 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt @@ -113,8 +113,8 @@ fun PerpsMarketItem( lineHeight = 14.sp, modifier = Modifier .clip(RoundedCornerShape(4.dp)) - .background(MixinAppTheme.colors.backgroundGrayLight,) - .padding(horizontal = 6.dp, vertical = 2.dp) + .background(MixinAppTheme.colors.backgroundGrayLight) + .padding(horizontal = 3.dp, vertical = 2.dp) ) } Spacer(modifier = Modifier.height(2.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt index 120afacf55..fa86e2c26e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt @@ -119,6 +119,7 @@ class PerpsMarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment( marketId = market.marketId, marketSymbol = market.symbol, marketDisplaySymbol = market.displaySymbol, + marketTokenSymbol = market.tokenSymbol, isLong = isLong ) dismiss() diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt index a699f4f26a..7fec5c23ef 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt @@ -53,6 +53,8 @@ class PerpsPositionShareActivity : BaseActivity() { companion object { private const val ARGS_POSITION = "args_position" private const val ARGS_POSITION_HISTORY = "args_position_history" + private const val SHARE_QR_URL = "https://mixin.one/mm" + private val MIN_DISPLAY_PNL_PERCENT = BigDecimal("-100") fun show(context: Context, position: PerpsPositionItem) { refreshScreenshot(context, 0x33000000) @@ -83,18 +85,20 @@ class PerpsPositionShareActivity : BaseActivity() { intent.extras?.getParcelableCompat(ARGS_POSITION_HISTORY, PerpsPositionHistoryItem::class.java) } - private val shareLink: String by lazy { + private val qrShareLink: String = SHARE_QR_URL + + private val copyShareLink: String by lazy { val identity = Session.getAccount()?.identityNumber ?: "" val productId = position?.productId ?: positionHistory?.productId if (productId != null) { - val baseUrl = "https://mixin.one/trade?type=perps&product=$productId" + val baseUrl = "${Constants.Scheme.HTTPS_TRADE}?type=perps&product=$productId" if (identity.isNotEmpty()) { "$baseUrl&referral=$identity" } else { baseUrl } } else { - throw IllegalArgumentException("lost data") + SHARE_QR_URL } } @@ -123,6 +127,7 @@ class PerpsPositionShareActivity : BaseActivity() { topMargin = 20.dp } binding.iconFl.round(6.dp) + binding.qr.post { binding.qr.round(4.dp) } val hasContent = bindContent() if (!hasContent) { @@ -240,7 +245,7 @@ class PerpsPositionShareActivity : BaseActivity() { } private fun bindFooter() { - val qrCode = shareLink.generateQRCode(72.dp, 8.dp).first + val qrCode = qrShareLink.generateQRCode(72.dp, 8.dp).first binding.qr.setImageBitmap(qrCode) } @@ -268,7 +273,7 @@ class PerpsPositionShareActivity : BaseActivity() { } private val onCopy: () -> Unit = { - getClipboardManager().setPrimaryClip(ClipData.newPlainText(null, shareLink)) + getClipboardManager().setPrimaryClip(ClipData.newPlainText(null, copyShareLink)) finish() toast(R.string.copied_to_clipboard) } @@ -328,12 +333,13 @@ class PerpsPositionShareActivity : BaseActivity() { } private fun formatSignedPercent(value: BigDecimal): String { + val displayValue = value.max(MIN_DISPLAY_PNL_PERCENT) val sign = when { - value > BigDecimal.ZERO -> "+" - value < BigDecimal.ZERO -> "-" + displayValue > BigDecimal.ZERO -> "+" + displayValue < BigDecimal.ZERO -> "-" else -> "" } - val number = value.abs().setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + val number = displayValue.abs().setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() return "$sign$number%" } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt index 3fac27f669..2cf30d9621 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -146,13 +146,12 @@ class PositionDetailFragment : BaseFragment() { } private fun openTradeAgain(positionHistory: PerpsPositionHistoryItem) { - val isLong = positionHistory.side.equals("long", ignoreCase = true) - PerpsActivity.showOpenPosition( + PerpsActivity.showDetail( context = requireContext(), marketId = positionHistory.productId, - marketSymbol = positionHistory.marketSymbol ?: positionHistory.tokenSymbol ?: "", - marketDisplaySymbol = positionHistory.displaySymbol ?: positionHistory.tokenSymbol ?: "", - isLong = isLong + marketSymbol = positionHistory.marketSymbol ?: positionHistory.tokenSymbol.orEmpty(), + marketDisplaySymbol = positionHistory.displaySymbol ?: positionHistory.tokenSymbol.orEmpty(), + marketTokenSymbol = positionHistory.tokenSymbol.orEmpty() ) } } 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 853bd0e46d..6d47a052cd 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 @@ -89,14 +89,6 @@ fun PositionDetailPage( return "$fiatSymbol${value.multiply(fiatRate).priceFormat()}" } - fun formatSignedFiat(value: BigDecimal): String { - return when { - value > BigDecimal.ZERO -> "+${formatFiat(value)}" - value < BigDecimal.ZERO -> "-${formatFiat(value.abs())}" - else -> formatFiat(BigDecimal.ZERO) - } - } - PageScaffold( title = title, verticalScrollable = false, @@ -181,7 +173,7 @@ fun PositionDetailPage( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.Close), + text = stringResource(R.string.Perpetual_Guide_Close), color = MixinAppTheme.colors.textPrimary, fontWeight = FontWeight.W500, modifier = Modifier diff --git a/app/src/main/java/one/mixin/android/ui/wallet/DepositShareActivity.kt b/app/src/main/java/one/mixin/android/ui/wallet/DepositShareActivity.kt index 8a47cd9013..d5319ec1de 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/DepositShareActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/DepositShareActivity.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import one.mixin.android.BuildConfig import one.mixin.android.Constants -import one.mixin.android.Constants.Scheme.HTTPS_MARKET import one.mixin.android.R import one.mixin.android.databinding.ActivityDepositShareBinding import one.mixin.android.db.web3.vo.Web3TokenItem @@ -60,6 +59,7 @@ class DepositShareActivity : BaseActivity() { private const val ARGS_AMOUNT = "amount" private const val ARGS_AMOUNT_URL = "amount_url" private const val ARGS_USER = "user" + private const val SHARE_QR_URL = "https://mixin.one/mm" fun show(context: Context, token: TokenItem?, address: String? = null, amountUrl: String? = null, amount: String? = null, user: User? = null) { refreshScreenshot(context, 0x33000000) @@ -148,6 +148,7 @@ class DepositShareActivity : BaseActivity() { window.statusBarColor = android.graphics.Color.TRANSPARENT binding.iconFl.round(6.dp) + binding.qr.post { binding.qr.round(4.dp) } binding.content.updateLayoutParams { topMargin = 20.dp } @@ -172,7 +173,7 @@ class DepositShareActivity : BaseActivity() { } } Session.getAccount()?.identityNumber.let { - val qrcodeContent = "$HTTPS_MARKET/${tokenAssetId}?ref=$it" + val qrcodeContent = SHARE_QR_URL val qrCode = qrcodeContent.generateQRCode(200.dp, 8.dp).first binding.qr.setImageBitmap(qrCode) } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/MarketShareActivity.kt b/app/src/main/java/one/mixin/android/ui/wallet/MarketShareActivity.kt index df953bcf30..d0e3a53d7d 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/MarketShareActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/MarketShareActivity.kt @@ -43,6 +43,7 @@ class MarketShareActivity : BaseActivity() { companion object { private const val ARGS_NAME = "name" private const val ARGS_COIN = "coin" + private const val SHARE_QR_URL = "https://mixin.one/mm" private var cover: Bitmap? = null fun show(context: Context, cover: Bitmap, name: String, coinId: String) { refreshScreenshot(context, 0x33000000) @@ -84,6 +85,7 @@ class MarketShareActivity : BaseActivity() { ) SystemUIManager.setSafePadding(window, android.graphics.Color.TRANSPARENT) binding.test.round(8.dp) + binding.qr.post { binding.qr.round(4.dp) } binding.content.updateLayoutParams { topMargin = 20.dp } @@ -91,7 +93,7 @@ class MarketShareActivity : BaseActivity() { binding.image.setImageBitmap(cropAndScaleBitmap(cover!!, 8.dp, (80 - 24 + 32).dp)) } Session.getAccount()?.identityNumber.let { - val qrcodeContent = "$HTTPS_MARKET/$coinId?ref=$it" + val qrcodeContent = SHARE_QR_URL val qrCode = qrcodeContent.generateQRCode(72.dp, 8.dp).first binding.qr.setImageBitmap(qrCode) } @@ -153,12 +155,11 @@ class MarketShareActivity : BaseActivity() { } private val onCopy: () -> Unit = { - Session.getAccount()?.identityNumber.let { - val link = "$HTTPS_MARKET/$coinId?ref=$it" - getClipboardManager().setPrimaryClip(ClipData.newPlainText(null, link)) - finish() - toast(R.string.copied_to_clipboard) - } + val marketLink = "$HTTPS_MARKET/$coinId" + val link = Session.getAccount()?.identityNumber?.let { "$marketLink?ref=$it" } ?: marketLink + getClipboardManager().setPrimaryClip(ClipData.newPlainText(null, link)) + finish() + toast(R.string.copied_to_clipboard) } private val onSave: () -> Unit = { diff --git a/app/src/main/res/layout/activity_deposit_share.xml b/app/src/main/res/layout/activity_deposit_share.xml index 0813e2a182..4a0264cda5 100644 --- a/app/src/main/res/layout/activity_deposit_share.xml +++ b/app/src/main/res/layout/activity_deposit_share.xml @@ -231,8 +231,8 @@ android:layout_gravity="center" /> @@ -339,4 +339,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/activity_market_share.xml b/app/src/main/res/layout/activity_market_share.xml index 9da2df52a3..0ce0cda460 100644 --- a/app/src/main/res/layout/activity_market_share.xml +++ b/app/src/main/res/layout/activity_market_share.xml @@ -91,8 +91,8 @@ @@ -197,4 +197,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/activity_perps_position_share.xml b/app/src/main/res/layout/activity_perps_position_share.xml index 0ad32bba48..99a739749c 100644 --- a/app/src/main/res/layout/activity_perps_position_share.xml +++ b/app/src/main/res/layout/activity_perps_position_share.xml @@ -156,27 +156,27 @@ android:layout_height="wrap_content" android:layout_marginTop="8dp" android:orientation="horizontal"> - + tools:text="$40,000.00" /> + android:gravity="end" + tools:text="$38,900.00" /> + @@ -231,8 +231,8 @@ android:layout_gravity="center" /> diff --git a/app/src/main/res/layout/item_market_list.xml b/app/src/main/res/layout/item_market_list.xml index 529367ff32..35e4304eb4 100644 --- a/app/src/main/res/layout/item_market_list.xml +++ b/app/src/main/res/layout/item_market_list.xml @@ -27,7 +27,6 @@ android:maxLines="1" android:textColor="?attr/text_primary" android:textSize="16sp" - android:textStyle="bold" app:layout_constraintEnd_toStartOf="@id/price_tv" app:layout_constraintStart_toEndOf="@id/icon_iv" app:layout_constraintTop_toTopOf="@id/icon_iv" @@ -54,7 +53,6 @@ android:layout_height="wrap_content" android:textColor="?attr/text_primary" android:textSize="16sp" - android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/symbol_tv" tools:text="$42,123.45" /> @@ -65,7 +63,6 @@ android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="12sp" - android:textStyle="bold" app:layout_constraintEnd_toEndOf="@id/price_tv" app:layout_constraintTop_toBottomOf="@id/price_tv" tools:text="+2.34%" diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0ab94a2d2f..0ca78b4886 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2279,6 +2279,8 @@ 当前合约持仓的实际交易价值,由「保证金 × 杠杆」决定。 用途 支撑当前仓位,抵扣浮动亏损。 + 支撑当前仓位 + 抵扣浮动亏损 当投入资金不足以支撑当前仓位时,仓位将被系统强制平仓。价格剧烈波动可能会快速消耗投入资金。 永续合约 开仓 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7c21e12f7..fc925e4dd8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2338,6 +2338,8 @@ The actual trading value of current contract position, determined by "Margin × Leverage". Usage Support current position and offset floating losses. + Support current position. + Offset floating losses. When investment is insufficient to support current position, the position will be forcibly liquidated by the system. Severe price volatility may rapidly consume investment. Perpetual Open Position From 6824fc36b8941b8e9e3b899ee810f00bbcef16fc Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 5 Mar 2026 20:03:49 +0800 Subject: [PATCH 046/105] feat(perps): polish position labels and open-position input UI --- .../android/job/RefreshPerpsPositionsJob.kt | 7 +- .../ui/home/web3/trade/InputTextField.kt | 13 +- .../home/web3/trade/perps/OpenPositionPage.kt | 37 ++- .../home/web3/trade/perps/PerpetualContent.kt | 24 +- .../web3/trade/perps/PerpetualViewModel.kt | 36 ++- .../PerpsCloseBottomSheetDialogFragment.kt | 248 ++++++++---------- .../web3/trade/perps/PerpsMarketDetailPage.kt | 5 +- .../trade/perps/PerpsPositionShareActivity.kt | 22 +- .../web3/trade/perps/PositionDetailPage.kt | 16 +- app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values/strings.xml | 6 +- 11 files changed, 215 insertions(+), 205 deletions(-) diff --git a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt index fac5a06c27..a535232c3b 100644 --- a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt +++ b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt @@ -47,11 +47,10 @@ class RefreshPerpsPositionsJob( val positions = response.data!!.map { it.copy(walletId = walletId) } Timber.d("RefreshPerpsPositionsJob: Fetched ${positions.size} positions for wallet $walletId") - if (positions.isEmpty()) { - positionDao.deleteOpenByWallet(walletId) - } else { - positionDao.deleteOpenByWalletAndNotIn(walletId, positions.map { it.positionId }) + if (positions.isNotEmpty()) { positionDao.insertAll(positions) + } else { + Timber.d("RefreshPerpsPositionsJob: Keep local positions when remote list is empty for wallet $walletId") } } else { Timber.e("RefreshPerpsPositionsJob: Failed to fetch positions for wallet $walletId: ${response.errorDescription}") 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 1899ae1d50..30188c066b 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 @@ -41,6 +41,7 @@ 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.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.request.ImageRequest @@ -62,6 +63,7 @@ fun InputContent( onInputChanged: ((String) -> Unit)? = null, readOnly: Boolean = false, inlineEndCompose: (@Composable () -> Unit)? = null, + tokenIconSize: Dp = 32.dp, ) { if (readOnly) { Column(modifier = Modifier.fillMaxWidth()) { @@ -84,7 +86,7 @@ fun InputContent( } } } - Right(token, selectClick) + Right(token, selectClick, tokenIconSize) } Text(text = "", style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Light)) // placeholder } @@ -177,7 +179,7 @@ fun InputContent( } } } - Right(token, selectClick) + Right(token, selectClick, tokenIconSize) } Text(text = "", style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Light)) // placeholder } @@ -188,6 +190,7 @@ fun InputContent( private fun Right( token: SwapToken?, selectClick: (() -> Unit)? = null, + tokenIconSize: Dp = 32.dp, ) { Row(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { selectClick?.invoke() }, verticalAlignment = Alignment.CenterVertically) { if (token?.collectionHash != null) { @@ -195,7 +198,7 @@ private fun Right( model = ImageRequest.Builder(LocalContext.current).data(token.icon).transformations(CoilRoundedHexagonTransformation()).build(), placeholder = R.drawable.ic_inscription_icon, modifier = Modifier - .size(30.dp), + .size(tokenIconSize), ) } else { Box { @@ -203,7 +206,7 @@ private fun Right( model = token?.icon ?: "", placeholder = R.drawable.ic_avatar_place_holder, modifier = Modifier - .size(32.dp) + .size(tokenIconSize) .clip(CircleShape), ) @@ -212,7 +215,7 @@ private fun Right( placeholder = R.drawable.ic_avatar_place_holder, modifier = Modifier .align(Alignment.BottomStart) - .size(13.dp) + .size(tokenIconSize * (13f / 32f)) .border(1.dp, MixinAppTheme.colors.background, CircleShape) .clip(CircleShape), ) 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 2e81183073..3d75f9b094 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 @@ -149,6 +149,11 @@ fun OpenPositionPage( val insufficientBalance = hasInputAmount && inputAmount > tokenBalance val canReview = hasInputAmount && !insufficientBalance val displayedErrorInfo = errorInfo?.takeIf { it.isNotBlank() } + val tokenNetworkName = currentToken?.chainName + ?.takeIf { it.isNotBlank() } + ?: currentToken?.chainSymbol + ?.takeIf { it.isNotBlank() } + ?: "" MixinAppTheme { PageScaffold( @@ -212,11 +217,25 @@ fun OpenPositionPage( .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) .padding(16.dp) ) { - Text( - text = stringResource(R.string.Amount), - fontSize = 14.sp, - color = MixinAppTheme.colors.textPrimary - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.Amount), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.weight(1f)) + if (tokenNetworkName.isNotBlank()) { + Text( + text = tokenNetworkName, + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + textAlign = TextAlign.End + ) + } + } Spacer(modifier = Modifier.height(8.dp)) @@ -226,7 +245,8 @@ fun OpenPositionPage( selectClick = { onTokenSelect() }, - onInputChanged = { usdtAmount = it } + onInputChanged = { usdtAmount = it }, + tokenIconSize = 25.dp ) Spacer(modifier = Modifier.height(8.dp)) @@ -363,7 +383,7 @@ fun OpenPositionPage( .background(Color.Transparent) .border( width = 1.dp, - color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.textAssist, + color = MixinAppTheme.colors.borderColor, shape = RoundedCornerShape(16.dp) ) .clickable { @@ -481,13 +501,14 @@ fun OpenPositionPage( if (displayedErrorInfo != null) { Text( text = displayedErrorInfo, - fontSize = 12.sp, + fontSize = 14.sp, color = MixinAppTheme.colors.walletRed, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 4.dp), textAlign = TextAlign.Center, ) + Spacer(modifier = Modifier.height(8.dp)) } Button( 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 f1e4b49f90..ea09dca50c 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 @@ -147,12 +147,14 @@ fun PerpetualContent( } } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - ) { + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { Spacer(modifier = Modifier.height(16.dp)) Column( @@ -422,11 +424,13 @@ fun PerpetualContent( } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) + } - // Long and Short Buttons Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Button( @@ -471,8 +475,6 @@ fun PerpetualContent( ) } } - - Spacer(modifier = Modifier.height(24.dp)) } } 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 1257be3864..f9fb00b403 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 @@ -335,20 +335,18 @@ class PerpetualViewModel @Inject constructor( if (response.isSuccess) { val remotePosition = response.data withContext(Dispatchers.IO) { - if (remotePosition == null || !remotePosition.state.equals("open", ignoreCase = true)) { - perpsPositionDao.deleteById(positionId) - } else { + if (remotePosition != null) { perpsPositionDao.insert( remotePosition.copy( walletId = walletId ?: remotePosition.walletId ) ) + } else { + Timber.d("Skip deleting local position when remote detail is null: $positionId") } } } else if (response.errorCode == ErrorHandler.NOT_FOUND) { - withContext(Dispatchers.IO) { - perpsPositionDao.deleteById(positionId) - } + Timber.d("Skip deleting local position on NOT_FOUND during refresh: $positionId") } else { Timber.e("Failed to refresh position detail: ${response.errorDescription}") } @@ -543,6 +541,9 @@ class PerpetualViewModel @Inject constructor( } if (response.isSuccess) { + withContext(Dispatchers.IO) { + perpsPositionDao.deleteById(positionId) + } Timber.d("Perps order closed: $positionId") onSuccess() } else { @@ -573,6 +574,18 @@ class PerpetualViewModel @Inject constructor( } } + suspend fun getPositionFromDb(positionId: String): PerpsPositionItem? { + return withContext(Dispatchers.IO) { + perpsPositionDao.getPosition(positionId) + } + } + + suspend fun getMarketFromDb(marketId: String): PerpsMarket? { + return withContext(Dispatchers.IO) { + perpsMarketDao.getMarket(marketId) + } + } + fun loadPositionDetail( positionId: String, onSuccess: (PerpsPosition) -> Unit, @@ -580,19 +593,24 @@ class PerpetualViewModel @Inject constructor( ) { viewModelScope.launch { try { + val localBefore = withContext(Dispatchers.IO) { + perpsPositionDao.getPosition(positionId) + } + val response = withContext(Dispatchers.IO) { routeService.getPerpsPosition(positionId) } val data = response.data if (response.isSuccess && data != null) { - Timber.d("Position detail loaded: ${data.positionId}") + val resolvedWalletId = data.walletId ?: localBefore?.walletId + val positionForDb = data.copy(walletId = resolvedWalletId) withContext(Dispatchers.IO) { - perpsPositionDao.insert(data) + perpsPositionDao.insert(positionForDb) } - onSuccess(data) + onSuccess(positionForDb) } else { val error = "Failed to load position detail: ${response.errorDescription}" Timber.e(error) 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 c7959988dd..7700c11451 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 @@ -49,7 +49,6 @@ import androidx.compose.ui.unit.sp import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R @@ -161,7 +160,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } private var step by mutableStateOf(Step.Pending) private var errorInfo: String? by mutableStateOf(null) - private var isLoading by mutableStateOf(true) private var latestMarkPrice by mutableStateOf("") private var latestUnrealizedPnl by mutableStateOf("") @@ -187,6 +185,24 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } LaunchedEffect(positionId) { + val localPosition = viewModel.getPositionFromDb(positionId) + localPosition?.let { position -> + latestMarkPrice = position.markPrice ?: latestMarkPrice + latestUnrealizedPnl = position.unrealizedPnl ?: latestUnrealizedPnl + latestRoe = position.roe ?: latestRoe + marketIconUrl = position.iconUrl.orEmpty() + marketSymbol = position.displaySymbol ?: position.tokenSymbol.orEmpty() + refreshAssetAndSender( + settleAssetId = position.settleAssetId, + botId = position.botId + ) + + viewModel.getMarketFromDb(position.productId)?.let { market -> + marketIconUrl = market.iconUrl + marketSymbol = market.displaySymbol + } + } + viewModel.loadPositionDetail( positionId = positionId, onSuccess = { position -> @@ -194,6 +210,13 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen latestUnrealizedPnl = position.unrealizedPnl ?: "0" latestRoe = position.roe ?: "0" + lifecycleScope.launch { + viewModel.getMarketFromDb(position.productId)?.let { market -> + marketIconUrl = market.iconUrl + marketSymbol = market.displaySymbol + } + } + viewModel.loadMarketDetail( marketId = position.productId, onSuccess = { market -> @@ -202,26 +225,13 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen }, onError = {} ) - - lifecycleScope.launch { - position.settleAssetId?.let { assetId -> - val asset = bottomViewModel.findAssetItemById(assetId) - asset?.let { - settleAssetSymbol = it.symbol - settleAssetItem = it - } - } - - position.botId?.let { botId -> - sender = bottomViewModel.refreshUser(botId) - } - - isLoading = false - } + refreshAssetAndSender( + settleAssetId = position.settleAssetId, + botId = position.botId + ) }, onError = { error -> Timber.e("Failed to load position detail: $error") - isLoading = false } ) } @@ -375,101 +385,87 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen "${fiatSymbol}0" } - if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(40.dp), - color = MixinAppTheme.colors.accent + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + Text( + text = stringResource(R.string.Perpetual), + color = MixinAppTheme.colors.textRemarks, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + CoilImage( + model = marketIconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = marketSymbol, + color = MixinAppTheme.colors.textPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.Bold ) } - } else { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - ) { + Box(modifier = Modifier.height(20.dp)) + settleAssetItem?.let { asset -> Text( - text = stringResource(R.string.Perpetual), + text = stringResource(R.string.Estimated_Receive), color = MixinAppTheme.colors.textRemarks, fontSize = 14.sp, ) Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { CoilImage( - model = marketIconUrl, + model = asset.iconUrl, placeholder = R.drawable.ic_avatar_place_holder, modifier = Modifier - .size(24.dp) + .size(18.dp) .clip(CircleShape), contentScale = ContentScale.Crop ) - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(6.dp)) Text( - text = marketSymbol, + text = "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", color = MixinAppTheme.colors.textPrimary, fontSize = 16.sp, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.W400 ) - } - Box(modifier = Modifier.height(20.dp)) - settleAssetItem?.let { asset -> - Text( - text = stringResource(R.string.Estimated_Receive), - color = MixinAppTheme.colors.textRemarks, - fontSize = 14.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - CoilImage( - model = asset.iconUrl, - placeholder = R.drawable.ic_avatar_place_holder, - modifier = Modifier - .size(18.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "${String.format("%.8f", estimatedReceive)} ${asset.symbol}", - color = MixinAppTheme.colors.textPrimary, - fontSize = 16.sp, - fontWeight = FontWeight.W400 - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = asset.chainName ?: "", - color = MixinAppTheme.colors.textAssist, - fontSize = 14.sp - ) - } - } - - Spacer(modifier = Modifier.height(4.dp)) - Row { + Spacer(modifier = Modifier.weight(1f)) Text( - text = "${stringResource(R.string.Perpetual_PnL)}: ", + text = asset.chainName ?: "", color = MixinAppTheme.colors.textAssist, fontSize = 14.sp ) - Text( - text = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} $settleAssetSymbol ($formattedRoe%)", - color = pnlColor, - fontSize = 14.sp - ) } } - Box(modifier = Modifier.height(20.dp)) - - ItemWalletContent(title = stringResource(id = R.string.Receiver).uppercase(), fontSize = 16.sp) - Box(modifier = Modifier.height(20.dp)) - ItemUserContent(title = stringResource(id = R.string.Sender).uppercase(), sender, null) + Spacer(modifier = Modifier.height(4.dp)) + Row { + Text( + text = "${stringResource(R.string.Perpetual_PnL)}: ", + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp + ) + Text( + text = "${if (pnl >= BigDecimal.ZERO) "+" else ""}${latestUnrealizedPnl} $settleAssetSymbol ($formattedRoe%)", + color = pnlColor, + fontSize = 14.sp + ) + } } + Box(modifier = Modifier.height(20.dp)) + + ItemWalletContent(title = stringResource(id = R.string.Receiver).uppercase(), fontSize = 16.sp) + Box(modifier = Modifier.height(20.dp)) + + ItemUserContent(title = stringResource(id = R.string.Sender).uppercase(), sender, null) Box(modifier = Modifier.height(16.dp)) } @@ -528,42 +524,6 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen } } - @Composable - private fun CloseInfoItem( - title: String, - value: String, - valueColor: Color = MixinAppTheme.colors.textPrimary, - subValue: String? = null, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - ) { - Text( - text = title, - color = MixinAppTheme.colors.textRemarks, - fontSize = 14.sp, - maxLines = 1, - ) - Box(modifier = Modifier.height(4.dp)) - Text( - text = value, - color = valueColor, - fontSize = 16.sp, - fontWeight = FontWeight.W400 - ) - subValue?.let { - Box(modifier = Modifier.height(4.dp)) - Text( - text = it, - color = MixinAppTheme.colors.textAssist, - fontSize = 14.sp, - ) - } - } - } - override fun getBottomSheetHeight(view: View): Int { return requireContext().screenHeight() - view.getSafeAreaInsetsTop() } @@ -579,23 +539,33 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen super.onDismiss(dialog) } - private fun closePosition() = lifecycleScope.launch(Dispatchers.IO) { - try { - step = Step.Sending + private fun closePosition() { + step = Step.Sending + viewModel.closePerpsOrder( + positionId = positionId, + onSuccess = { + step = Step.Done + }, + onError = { error -> + errorInfo = error + step = Step.Error + } + ) + } - viewModel.closePerpsOrder( - positionId = positionId, - onSuccess = { - viewModel.deletePosition(positionId) - step = Step.Done - }, - onError = { error -> - errorInfo = error - step = Step.Error + private fun refreshAssetAndSender(settleAssetId: String?, botId: String?) { + lifecycleScope.launch { + settleAssetId?.let { assetId -> + val asset = bottomViewModel.findAssetItemById(assetId) + asset?.let { + settleAssetSymbol = it.symbol + settleAssetItem = it } - ) - } catch (e: Exception) { - handleException(e) + } + + botId?.let { userId -> + sender = bottomViewModel.refreshUser(userId) + } } } 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 b5c2e7d604..6e4c131ded 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 @@ -652,16 +652,17 @@ private fun OpenPositionCard( color = MixinAppTheme.colors.textAssist ) Spacer(modifier = Modifier.height(4.dp)) - Row { + Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .clip(RoundedCornerShape(6.dp)) .background(directionColor) - .padding(horizontal = 8.dp, vertical = 0.5.dp) + .padding(horizontal = 3.dp, vertical = 1.dp) ) { Text( text = if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short), fontSize = 10.sp, + lineHeight = 12.sp, color = Color.White ) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt index 7fec5c23ef..24ab9789c7 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt @@ -38,7 +38,6 @@ import one.mixin.android.extension.priceFormat import one.mixin.android.extension.round import one.mixin.android.extension.supportsS import one.mixin.android.extension.toast -import one.mixin.android.session.Session import one.mixin.android.ui.common.BaseActivity import one.mixin.android.ui.web.getScreenshot import one.mixin.android.ui.web.refreshScreenshot @@ -85,22 +84,7 @@ class PerpsPositionShareActivity : BaseActivity() { intent.extras?.getParcelableCompat(ARGS_POSITION_HISTORY, PerpsPositionHistoryItem::class.java) } - private val qrShareLink: String = SHARE_QR_URL - - private val copyShareLink: String by lazy { - val identity = Session.getAccount()?.identityNumber ?: "" - val productId = position?.productId ?: positionHistory?.productId - if (productId != null) { - val baseUrl = "${Constants.Scheme.HTTPS_TRADE}?type=perps&product=$productId" - if (identity.isNotEmpty()) { - "$baseUrl&referral=$identity" - } else { - baseUrl - } - } else { - SHARE_QR_URL - } - } + private val shareLink: String = SHARE_QR_URL private val quoteColorReversed: Boolean by lazy { defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) @@ -245,7 +229,7 @@ class PerpsPositionShareActivity : BaseActivity() { } private fun bindFooter() { - val qrCode = qrShareLink.generateQRCode(72.dp, 8.dp).first + val qrCode = shareLink.generateQRCode(72.dp, 8.dp).first binding.qr.setImageBitmap(qrCode) } @@ -273,7 +257,7 @@ class PerpsPositionShareActivity : BaseActivity() { } private val onCopy: () -> Unit = { - getClipboardManager().setPrimaryClip(ClipData.newPlainText(null, copyShareLink)) + getClipboardManager().setPrimaryClip(ClipData.newPlainText(null, shareLink)) finish() toast(R.string.copied_to_clipboard) } 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 6d47a052cd..394be87d87 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 @@ -76,7 +76,11 @@ fun PositionDetailPage( } else { stringResource(R.string.Short) } - val title = stringResource(R.string.Perpetual_Opened_Side_Title, sideText) + val title = if (isLong) { + stringResource(R.string.Perpetual_Opened_Long_Title) + } else { + stringResource(R.string.Perpetual_Opened_Short_Title) + } val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val absQuantity = quantity.abs() @@ -151,7 +155,7 @@ fun PositionDetailPage( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(sideColor.copy(alpha = 0.2f)) - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = 3.dp, vertical = 2.dp) .align(Alignment.CenterHorizontally) ) { Text( @@ -353,7 +357,11 @@ fun PositionDetailPage( } else { stringResource(R.string.Short) } - val title = stringResource(R.string.Perpetual_Closed_Side_Title, sideText) + val title = if (isLong) { + stringResource(R.string.Perpetual_Closed_Long_Title) + } else { + stringResource(R.string.Perpetual_Closed_Short_Title) + } val quantity = positionHistory.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val absQuantity = quantity.abs() @@ -429,7 +437,7 @@ fun PositionDetailPage( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(sideColor.copy(alpha = 0.2f)) - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = 3.dp, vertical = 2.dp) .align(Alignment.CenterHorizontally) ) { Text( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0ca78b4886..c248d67cf3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2313,8 +2313,10 @@ 确认平仓 平仓成功 平仓 - %1$s持仓 - %1$s平仓 + 开仓做多 + 开仓做空 + 平仓做多 + 平仓做空 预估强平价格 持仓总价值 %1$s %2$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc925e4dd8..3b0ee68906 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2378,8 +2378,10 @@ Confirm Close Position Close Position Success Close Position - Opened %1$s - Closed %1$s + Open Long + Open Short + Close Long + Close Short Estimated Liquidation Price Total Position Value %1$s %2$s From 77068362bd7c12127fe0c7a850f09249549c7de3 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 6 Mar 2026 10:30:28 +0800 Subject: [PATCH 047/105] Max leverage --- .../home/web3/trade/perps/OpenPositionPage.kt | 42 +++++++++++-------- .../web3/trade/perps/PerpsMarketDetailPage.kt | 1 - app/src/main/res/drawable/bg_card.xml | 6 +-- 3 files changed, 27 insertions(+), 22 deletions(-) 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 3d75f9b094..cc7d1d409e 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 @@ -4,6 +4,7 @@ import PageScaffold import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -104,8 +105,12 @@ fun OpenPositionPage( var errorInfo by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() - val savedLeverage = context.defaultSharedPreferences.getInt(getLeveragePrefKey(marketId), 10) - var leverage by remember { mutableFloatStateOf(savedLeverage.toFloat()) } + val savedLeverage = remember(marketId) { + context.defaultSharedPreferences + .getInt(getLeveragePrefKey(marketId), 10) + .coerceAtLeast(1) + } + var leverage by remember(marketId) { mutableFloatStateOf(savedLeverage.toFloat()) } LaunchedEffect(marketId) { while (true) { @@ -135,11 +140,18 @@ fun OpenPositionPage( currentToken = availableTokens.firstOrNull { it.assetId == target.assetId } ?: target } } + val maxLeverage = currentMarket.leverage.coerceAtLeast(1) LaunchedEffect(usdtAmount, leverage, currentToken?.assetId) { errorInfo = null } + LaunchedEffect(maxLeverage, marketId) { + val boundedLeverage = leverage.coerceIn(1f, maxLeverage.toFloat()) + if (boundedLeverage != leverage) { + leverage = boundedLeverage + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), boundedLeverage.toInt()) + } + } - val maxLeverage = currentMarket.leverage val leverageOptions = generateLeverageOptions(maxLeverage) val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() @@ -249,8 +261,6 @@ fun OpenPositionPage( tokenIconSize = 25.dp ) - Spacer(modifier = Modifier.height(8.dp)) - Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -358,7 +368,9 @@ fun OpenPositionPage( Spacer(modifier = Modifier.height(12.dp)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { leverageOptions.forEach { lev -> @@ -617,22 +629,16 @@ fun OpenPositionPage( } private fun generateLeverageOptions(maxLeverage: Int): List { - val options = mutableListOf() + val safeMaxLeverage = maxLeverage.coerceAtLeast(1) val baseOptions = listOf(1, 2, 5, 10, 20) + val options = baseOptions + .filter { it in 1 until safeMaxLeverage } + .toMutableList() - baseOptions.forEach { option -> - if (option < maxLeverage) { - options.add(option) - } - } - - if (options.size < 5) { - options.add(maxLeverage) - } - + options.add(safeMaxLeverage) options.add(-1) - return options.take(7) + return options.distinct() } @Composable 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 6e4c131ded..cc6b164157 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 @@ -149,7 +149,6 @@ fun PerpsMarketDetailPage( .padding(horizontal = 16.dp) .padding(bottom = 80.dp) ) { - Spacer(modifier = Modifier.height(16.dp)) Column( modifier = Modifier diff --git a/app/src/main/res/drawable/bg_card.xml b/app/src/main/res/drawable/bg_card.xml index e3cf343fe3..100ccfc6bd 100644 --- a/app/src/main/res/drawable/bg_card.xml +++ b/app/src/main/res/drawable/bg_card.xml @@ -1,9 +1,9 @@ - + android:width="0.8dp" + android:color="@color/nftBorder" /> + \ No newline at end of file From 5e688c404696e57cddc4913639871b13a1e514b2 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 6 Mar 2026 10:43:07 +0800 Subject: [PATCH 048/105] Guide tab --- .../ui/home/web3/trade/perps/OpenPositionPage.kt | 2 +- .../web3/trade/perps/PerpetualGuideFragment.kt | 16 +++++++++++++++- .../home/web3/trade/perps/PerpetualGuidePage.kt | 9 ++++++--- .../PerpsConfirmBottomSheetDialogFragment.kt | 2 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 2 +- 5 files changed, 24 insertions(+), 7 deletions(-) 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 cc7d1d409e..0b69a96f63 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 @@ -472,7 +472,7 @@ fun OpenPositionPage( .size(12.dp) .clickable { val activity = context as? FragmentActivity ?: return@clickable - PerpetualGuideFragment.newInstance() + PerpetualGuideFragment.newInstance(PerpetualGuideFragment.TAB_POSITION) .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) }, tint = MixinAppTheme.colors.textAssist diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt index a669ea26f1..d52a0f06eb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt @@ -2,6 +2,7 @@ package one.mixin.android.ui.home.web3.trade.perps import android.annotation.SuppressLint import android.app.Dialog +import android.os.Bundle import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -21,8 +22,19 @@ class PerpetualGuideFragment : MixinComposeBottomSheetDialogFragment() { companion object { const val TAG = "PerpetualGuideFragment" + private const val ARGS_INITIAL_TAB = "args_initial_tab" - fun newInstance() = PerpetualGuideFragment() + const val TAB_OVERVIEW = 0 + const val TAB_LONG = 1 + const val TAB_SHORT = 2 + const val TAB_LEVERAGE = 3 + const val TAB_POSITION = 4 + + fun newInstance(initialTab: Int = TAB_OVERVIEW) = PerpetualGuideFragment().apply { + arguments = Bundle().apply { + putInt(ARGS_INITIAL_TAB, initialTab) + } + } } override fun getTheme() = R.style.AppTheme_Dialog @@ -52,8 +64,10 @@ class PerpetualGuideFragment : MixinComposeBottomSheetDialogFragment() { @Composable override fun ComposeContent() { + val initialTab = arguments?.getInt(ARGS_INITIAL_TAB, TAB_OVERVIEW) ?: TAB_OVERVIEW MixinAppTheme { PerpetualGuidePage( + initialTab = initialTab, pop = { dismiss() } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 5e8407f8f1..1b5bc03d9f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -72,10 +72,11 @@ data class GuideRowData( ) @Composable -fun PerpetualGuidePage(pop: () -> Unit) { - var selectedTab by remember { mutableIntStateOf(0) } +fun PerpetualGuidePage( + initialTab: Int = PerpetualGuideFragment.TAB_OVERVIEW, + pop: () -> Unit, +) { val coroutineScope = rememberCoroutineScope() - val tabs = listOf( stringResource(R.string.Perpetual_Guide_Overview), stringResource(R.string.Perpetual_Guide_Long), @@ -83,6 +84,8 @@ fun PerpetualGuidePage(pop: () -> Unit) { stringResource(R.string.Perpetual_Guide_Leverage), stringResource(R.string.Perpetual_Guide_Position) ) + val safeInitialTab = initialTab.coerceIn(0, tabs.lastIndex) + var selectedTab by remember(safeInitialTab) { mutableIntStateOf(safeInitialTab) } MixinAppTheme { Column( 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 71d248e1f3..5b8046fd7d 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 @@ -497,7 +497,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm modifier = Modifier .size(12.dp) .clickable { - PerpetualGuideFragment.newInstance() + PerpetualGuideFragment.newInstance(PerpetualGuideFragment.TAB_POSITION) .show(parentFragmentManager, PerpetualGuideFragment.TAG) }, tint = MixinAppTheme.colors.textAssist 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 cc6b164157..6eff8ce77a 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 @@ -197,7 +197,7 @@ fun PerpsMarketDetailPage( market = market!!, onLearnClick = { val activity = context as? FragmentActivity ?: return@MarketInfoCard - PerpetualGuideFragment.newInstance() + PerpetualGuideFragment.newInstance(PerpetualGuideFragment.TAB_OVERVIEW) .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) } ) From 884a6d86c94f0df8896a809db12bbd7d9555730a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 6 Mar 2026 11:38:46 +0800 Subject: [PATCH 049/105] fix(trade): separate perps order badge from tab badge and remove test reset hook --- .../main/java/one/mixin/android/Constants.kt | 2 + .../one/mixin/android/ui/home/MainActivity.kt | 52 ++++++------------- .../ui/home/web3/trade/TradeFragment.kt | 43 ++++++++++++--- .../android/ui/home/web3/trade/TradePage.kt | 24 +++++++-- 4 files changed, 72 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index 0f412d141a..5bdf3b44de 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -113,9 +113,11 @@ object Constants { const val PREF_HAS_USED_SWAP = "pref_has_used_swap" const val PREF_HAS_USED_SWAP_TRANSACTION = "pref_has_used_swap_transaction" // -1: No data, 0: Never used, 1: Used before const val PREF_HAS_USED_MARKET = "pref_has_used_market" + const val PREF_NAV_MORE_BADGE_DISMISSED = "pref_nav_more_badge_dismissed" const val PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED = "pref_trade_limit_order_badge_dismissed" const val PREF_TRADE_PERPETUAL_BADGE_DISMISSED = "pref_trade_perpetual_badge_dismissed" + const val PREF_TRADE_PERPETUAL_ORDER_BADGE_DISMISSED = "pref_trade_perpetual_order_badge_dismissed" const val PREF_PERPS_ACCEPTED_ASSET_IDS = "pref_perps_accepted_asset_ids" const val PREF_USED_WALLET = "pref_used_wallet" diff --git a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt index 891f842f65..695ff0a403 100644 --- a/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt @@ -9,7 +9,6 @@ import android.app.NotificationManager import android.content.Context import android.content.Intent import android.content.IntentSender -import android.graphics.Color import android.os.Build import android.os.Bundle import android.os.PowerManager @@ -143,8 +142,6 @@ import one.mixin.android.ui.common.VerifyFragment import one.mixin.android.ui.common.biometric.buildTransferBiometricItem import one.mixin.android.ui.conversation.ConversationActivity import one.mixin.android.ui.conversation.link.LinkBottomSheetDialogFragment -import one.mixin.android.ui.home.ExploreFragment.Companion.PREF_BOT_CLICKED_IDS -import one.mixin.android.ui.home.ExploreFragment.Companion.SHOW_DOT_BOT_IDS import one.mixin.android.ui.home.circle.CirclesFragment import one.mixin.android.ui.home.circle.ConversationCircleEditFragment import one.mixin.android.ui.home.reminder.ReminderBottomSheetDialogFragment @@ -346,27 +343,9 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba RxBus.listen(BadgeEvent::class.java) .autoDispose(destroyScope) .subscribe { e -> - lifecycleScope.launch{ + lifecycleScope.launch { when (e.badge) { - Account.PREF_HAS_USED_SWAP, Account.PREF_HAS_USED_BUY, Account.PREF_HAS_USED_WALLET_LIST -> { - binding.bottomNav.getOrCreateBadge(R.id.nav_wallet).apply { - isVisible = defaultSharedPreferences.getBoolean(Account.PREF_HAS_USED_WALLET_LIST, true) || defaultSharedPreferences.getBoolean(Account.PREF_HAS_USED_BUY, true) || - defaultSharedPreferences.getBoolean(Account.PREF_HAS_USED_SWAP, true) - backgroundColor = Color.RED - } - } - - Account.PREF_HAS_USED_MARKET, PREF_BOT_CLICKED_IDS -> { - binding.bottomNav.getOrCreateBadge(R.id.nav_more).apply { - isVisible = try { - defaultSharedPreferences.getString(PREF_BOT_CLICKED_IDS, "") - ?.split(",")?.toSet() ?: emptySet() - } catch (_: Exception) { - emptySet() - }.size != SHOW_DOT_BOT_IDS.size || defaultSharedPreferences.getBoolean(Account.PREF_HAS_USED_MARKET, true) - backgroundColor = Color.RED - } - } + Account.PREF_NAV_MORE_BADGE_DISMISSED -> updateNavMoreBadge() } } } @@ -1016,24 +995,19 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba } lifecycleScope.launch { - val swap = defaultSharedPreferences.getBoolean(Account.PREF_HAS_USED_WALLET_LIST, true) || defaultSharedPreferences.getBoolean(Account.PREF_HAS_USED_BUY, true) || - defaultSharedPreferences.getBoolean(Account.PREF_HAS_USED_SWAP, true) - binding.bottomNav.getOrCreateBadge(R.id.nav_wallet).apply { - isVisible = swap + isVisible = false backgroundColor = this@MainActivity.colorFromAttribute(R.attr.badge_red) } + updateNavMoreBadge() + } + } - val market = try { - defaultSharedPreferences.getString(PREF_BOT_CLICKED_IDS, "") - ?.split(",")?.toSet() ?: emptySet() - } catch (_: Exception) { - emptySet() - }.size < SHOW_DOT_BOT_IDS.size || defaultSharedPreferences.getBoolean(Account.PREF_HAS_USED_MARKET, true) - binding.bottomNav.getOrCreateBadge(R.id.nav_more).apply { - isVisible = market - backgroundColor = this@MainActivity.colorFromAttribute(R.attr.badge_red) - } + private fun updateNavMoreBadge() { + val dismissed = defaultSharedPreferences.getBoolean(Account.PREF_NAV_MORE_BADGE_DISMISSED, false) + binding.bottomNav.getOrCreateBadge(R.id.nav_more).apply { + isVisible = !dismissed + backgroundColor = this@MainActivity.colorFromAttribute(R.attr.badge_red) } } @@ -1109,6 +1083,10 @@ class MainActivity : BlazeBaseActivity(), WalletMissingBtcAddressFragment.Callba } R.id.nav_more -> { + if (!defaultSharedPreferences.getBoolean(Account.PREF_NAV_MORE_BADGE_DISMISSED, false)) { + defaultSharedPreferences.putBoolean(Account.PREF_NAV_MORE_BADGE_DISMISSED, true) + RxBus.publish(BadgeEvent(Account.PREF_NAV_MORE_BADGE_DISMISSED)) + } switchToDestination(NavigationController.Explore) lastBottomNavItemId = itemId findFragmentByTagTyped(NavigationController.ConversationList.tag)?.hideCircles() 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 b6b85c2e0c..81c0e74ba3 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 @@ -166,6 +166,18 @@ class TradeFragment : BaseFragment() { private var orderBadge: Boolean by mutableStateOf(false) + private fun limitOrderBadgeDismissedPrefKey(walletId: String): String { + return "${Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED}_$walletId" + } + + private fun perpetualBadgeDismissedPrefKey(walletId: String): String { + return "${Account.PREF_TRADE_PERPETUAL_BADGE_DISMISSED}_$walletId" + } + + private fun perpetualOrderBadgeDismissedPrefKey(walletId: String): String { + return "${Account.PREF_TRADE_PERPETUAL_ORDER_BADGE_DISMISSED}_$walletId" + } + @FlowPreview override fun onCreateView( inflater: LayoutInflater, @@ -240,21 +252,29 @@ class TradeFragment : BaseFragment() { } val currentWalletId = walletId ?: Session.getAccountId() ?: "" + val limitBadgePrefKey = remember(currentWalletId) { + limitOrderBadgeDismissedPrefKey(currentWalletId) + } + val perpetualBadgePrefKey = remember(currentWalletId) { + perpetualBadgeDismissedPrefKey(currentWalletId) + } + val perpetualOrderBadgePrefKey = remember(currentWalletId) { + perpetualOrderBadgeDismissedPrefKey(currentWalletId) + } val initialTabIndex = remember(currentWalletId) { val preferenceKey = "$PREF_TRADE_SELECTED_TAB_PREFIX$currentWalletId" defaultSharedPreferences.getInt(preferenceKey, 0) } var isLimitOrderTabBadgeDismissed by remember(currentWalletId) { - mutableStateOf(defaultSharedPreferences.getBoolean(Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED, false)) + mutableStateOf(defaultSharedPreferences.getBoolean(limitBadgePrefKey, false)) } var isPerpetualTabBadgeDismissed by remember(currentWalletId) { - mutableStateOf(defaultSharedPreferences.getBoolean(Account.PREF_TRADE_PERPETUAL_BADGE_DISMISSED, false)) + mutableStateOf(defaultSharedPreferences.getBoolean(perpetualBadgePrefKey, false)) } - - if (!isLimitOrderTabBadgeDismissed) { - isLimitOrderTabBadgeDismissed = true - defaultSharedPreferences.putBoolean(Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED, true) + var isPerpetualOrderBadgeDismissed by remember(currentWalletId) { + mutableStateOf(defaultSharedPreferences.getBoolean(perpetualOrderBadgePrefKey, false)) } + TradePage( walletId = walletId, swapFrom = fromToken, @@ -265,6 +285,7 @@ class TradeFragment : BaseFragment() { orderBadge = orderBadge, isLimitOrderTabBadgeDismissed = isLimitOrderTabBadgeDismissed, isPerpetualTabBadgeDismissed = isPerpetualTabBadgeDismissed, + isPerpetualOrderBadgeDismissed = isPerpetualOrderBadgeDismissed, initialAmount = initialAmount, lastOrderTime = lastOrderTime, reviewing = reviewing, @@ -280,13 +301,19 @@ class TradeFragment : BaseFragment() { onDismissLimitOrderTabBadge = { if (!isLimitOrderTabBadgeDismissed) { isLimitOrderTabBadgeDismissed = true - defaultSharedPreferences.putBoolean(Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED, true) + defaultSharedPreferences.putBoolean(limitBadgePrefKey, true) } }, onDismissPerpetualTabBadge = { if (!isPerpetualTabBadgeDismissed) { isPerpetualTabBadgeDismissed = true - defaultSharedPreferences.putBoolean(Account.PREF_TRADE_PERPETUAL_BADGE_DISMISSED, true) + defaultSharedPreferences.putBoolean(perpetualBadgePrefKey, true) + } + }, + onDismissPerpetualOrderBadge = { + if (!isPerpetualOrderBadgeDismissed) { + isPerpetualOrderBadgeDismissed = true + defaultSharedPreferences.putBoolean(perpetualOrderBadgePrefKey, true) } }, onTabChanged = { index -> 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 c238371782..8fb698e194 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 @@ -78,6 +78,7 @@ fun TradePage( orderBadge: Boolean, isLimitOrderTabBadgeDismissed: Boolean, isPerpetualTabBadgeDismissed: Boolean, + isPerpetualOrderBadgeDismissed: Boolean, initialAmount: String?, lastOrderTime: Long?, reviewing: Boolean, @@ -90,6 +91,7 @@ fun TradePage( onOrderList: (String, Boolean) -> Unit, onDismissLimitOrderTabBadge: () -> Unit, onDismissPerpetualTabBadge: () -> Unit, + onDismissPerpetualOrderBadge: () -> Unit, onTabChanged: (Int) -> Unit, onSwitchToLimitOrder: (String, SwapToken, SwapToken) -> Unit, pop: () -> Unit, @@ -271,10 +273,13 @@ fun TradePage( verticalScrollable = true, pop = pop, actions = { + val isPerpetualOrderEntry = perpetualTabIndex != null && pagerState.currentPage == perpetualTabIndex Box { IconButton(onClick = { - // If on Perpetual tab (page 2), show closed positions - if (perpetualTabIndex != null && pagerState.currentPage == perpetualTabIndex) { + if (isPerpetualOrderEntry) { + if (!isPerpetualOrderBadgeDismissed) { + onDismissPerpetualOrderBadge() + } onShowAllClosedPositions() } else { onOrderList(currentWalletId, false) @@ -286,7 +291,7 @@ fun TradePage( tint = MixinAppTheme.colors.icon, ) } - if (pendingOrderCount > 0) { + if (!isPerpetualOrderEntry && pendingOrderCount > 0) { Box( modifier = Modifier .offset(x = (-8).dp, y = (8).dp) @@ -302,7 +307,18 @@ fun TradePage( color = Color.White, ) } - } else if (orderBadge) { + } else if (!isPerpetualOrderEntry && orderBadge) { + Box( + modifier = Modifier + .size(8.dp) + .offset(x = (-12).dp, y = (12).dp) + .background( + color = MixinAppTheme.colors.badgeRed, + shape = CircleShape + ) + .align(Alignment.TopEnd) + ) + } else if (isPerpetualOrderEntry && !isPerpetualOrderBadgeDismissed) { Box( modifier = Modifier .size(8.dp) From 462d3f1a35075334dfd3aca717a169dd561fcde2 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 6 Mar 2026 15:11:26 +0800 Subject: [PATCH 050/105] feat(trade): refine perps order badge, guide controls, and position detail UI --- .../web3/trade/TotalPositionValueAdapter.kt | 26 ++--- .../android/ui/home/web3/trade/TradePage.kt | 27 ++++++ .../home/web3/trade/perps/OpenPositionPage.kt | 6 +- .../home/web3/trade/perps/PerpetualContent.kt | 10 +- .../web3/trade/perps/PerpetualGuidePage.kt | 94 ++++++++++++------- .../web3/trade/perps/PerpsMarketDetailPage.kt | 17 +++- .../web3/trade/perps/PositionDetailPage.kt | 35 ++++++- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 161 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index f0146b29fa..6bb3a463c5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -66,28 +66,30 @@ class TotalPositionValueAdapter : RecyclerView.Adapter= BigDecimal.ZERO valueTv.setTextColor( if (isProfit) { - context.getColor(R.color.wallet_green) - } else { - context.getColor(R.color.wallet_red) - } - ) - subtitleTv.setTextColor( - if (isProfit) { - context.getColor(R.color.wallet_green) + gainColor } else { - context.getColor(R.color.wallet_red) + lossColor } ) } else { valueTv.setTextColor(resolveAttrColor(itemView, R.attr.text_primary)) - subtitleTv.setTextColor(resolveAttrColor(itemView, R.attr.text_assist)) } - + + subtitleTv.setTextColor( + when { + subtitlePercent > BigDecimal.ZERO -> gainColor + subtitlePercent < BigDecimal.ZERO -> lossColor + else -> resolveAttrColor(itemView, R.attr.text_assist) + } + ) + subtitleTv.text = context.getString( R.string.Perpetual_Amount_Percent_Format, formatSignedUsd(subtitleValue), 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 8fb698e194..5f8def3483 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 @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R @@ -62,6 +63,7 @@ import one.mixin.android.session.Session import one.mixin.android.ui.components.TabItem import one.mixin.android.ui.home.web3.components.OutlinedTab import one.mixin.android.ui.home.web3.trade.perps.PerpetualContent +import one.mixin.android.ui.home.web3.trade.perps.PerpetualViewModel import one.mixin.android.vo.WalletCategory import java.math.BigDecimal @@ -108,6 +110,7 @@ fun TradePage( val context = LocalContext.current val viewModel = hiltViewModel() + val perpsViewModel = hiltViewModel() var walletDisplayName by remember { mutableStateOf(null) } var pendingOrderCount by remember { mutableIntStateOf(0) } @@ -119,6 +122,14 @@ fun TradePage( val currentWalletId = walletId ?: Session.getAccountId() ?: "" val pendingCount by viewModel.getPendingOrderCountByWallet(currentWalletId).collectAsStateWithLifecycle(initialValue = 0) + val openPerpetualPositions by remember(currentWalletId, walletId) { + if (walletId == null && currentWalletId.isNotEmpty()) { + perpsViewModel.observeOpenPositions(currentWalletId) + } else { + flowOf(emptyList()) + } + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val openPerpetualCount = openPerpetualPositions.size LaunchedEffect(pendingCount) { pendingOrderCount = pendingCount @@ -307,6 +318,22 @@ fun TradePage( color = Color.White, ) } + } else if (isPerpetualOrderEntry && openPerpetualCount > 0) { + Box( + modifier = Modifier + .offset(x = (-8).dp, y = (8).dp) + .clip(RoundedCornerShape(16.dp)) + .background(color = Color(0xFF3D75E3)) + .padding(vertical = 2.dp, horizontal = 6.dp) + .align(Alignment.TopEnd) + ) { + Text( + text = "${if (openPerpetualCount > 99) "99+" else openPerpetualCount}", + fontSize = 10.sp, + lineHeight = 11.sp, + color = Color.White, + ) + } } else if (!isPerpetualOrderEntry && orderBadge) { Box( modifier = Modifier diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt index 0b69a96f63..4a09830849 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 @@ -395,7 +395,7 @@ fun OpenPositionPage( .background(Color.Transparent) .border( width = 1.dp, - color = MixinAppTheme.colors.borderColor, + color = if (isSelected) MixinAppTheme.colors.accent else MixinAppTheme.colors.borderColor, shape = RoundedCornerShape(16.dp) ) .clickable { @@ -455,7 +455,7 @@ fun OpenPositionPage( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 4.dp), + .padding(horizontal = 16.dp), ) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row (verticalAlignment = Alignment.CenterVertically) { @@ -484,7 +484,7 @@ fun OpenPositionPage( color = MixinAppTheme.colors.textAssist ) } - + Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row (verticalAlignment = Alignment.CenterVertically) { Text( 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 ea09dca50c..eaf91fcb6c 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 @@ -264,7 +264,15 @@ fun PerpetualContent( openPositionsPreview.forEach { position -> OpenPositionItem( position = position, - onClick = { onOpenPositionClick(position) }) + onClick = { + val targetMarket = markets.firstOrNull { it.marketId == position.productId } + if (targetMarket != null) { + onMarketItemClick(targetMarket) + } else { + onOpenPositionClick(position) + } + } + ) Spacer(modifier = Modifier.height(12.dp)) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 1b5bc03d9f..a0c73f0f6a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -63,6 +63,7 @@ data class ScenarioData( val basePnlPercent: Int, val pnlAsset: String = "USDT", val isProfit: Boolean, + val maxPercent: Int? = null, ) data class GuideRowData( @@ -202,7 +203,7 @@ private fun LongContent() { scenarios = listOf( ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), - change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + change = stringResource(R.string.Perpetual_Price_Up_Amplitude), initialPercent = 10, basePnlAmount = 100, basePnlPercent = 10, @@ -210,11 +211,12 @@ private fun LongContent() { ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), - change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + change = stringResource(R.string.Perpetual_Price_Down_Amplitude), initialPercent = 10, basePnlAmount = 100, basePnlPercent = 10, - isProfit = false + isProfit = false, + maxPercent = 100, ) ) ) @@ -253,20 +255,21 @@ private fun ShortContent() { ), scenarios = listOf( ScenarioData( - scenario = stringResource(R.string.Perpetual_Price_Up), + scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), initialPercent = 10, basePnlAmount = 100, basePnlPercent = 10, - isProfit = false + isProfit = true, + maxPercent = 100, ), ScenarioData( - scenario = stringResource(R.string.Perpetual_Price_Down), + scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), initialPercent = 10, basePnlAmount = 100, basePnlPercent = 10, - isProfit = true + isProfit = false ) ) ) @@ -306,7 +309,7 @@ private fun LeverageContent() { scenarios = listOf( ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), - change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + change = stringResource(R.string.Perpetual_Price_Up_Amplitude), initialPercent = 10, basePnlAmount = 1000, basePnlPercent = 100, @@ -314,11 +317,12 @@ private fun LeverageContent() { ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), - change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + change = stringResource(R.string.Perpetual_Price_Down_Amplitude), initialPercent = 10, basePnlAmount = 1000, basePnlPercent = 100, - isProfit = false + isProfit = false, + maxPercent = 100, ) ) ) @@ -380,7 +384,7 @@ private fun PositionContent() { scenarios = listOf( ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), - change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + change = stringResource(R.string.Perpetual_Price_Up_Amplitude), initialPercent = 10, basePnlAmount = basePnlAmount, basePnlPercent = basePnlPercent, @@ -388,15 +392,16 @@ private fun PositionContent() { ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), - change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + change = stringResource(R.string.Perpetual_Price_Down_Amplitude), initialPercent = 10, basePnlAmount = basePnlAmount, basePnlPercent = basePnlPercent, - isProfit = false + isProfit = false, + maxPercent = 100, ) ), leverageValue = leverage, - onLeverageChange = { leverage = it.coerceIn(0, 100) }, + onLeverageChange = { leverage = it.coerceIn(0, 200) }, investmentValue = investment, onInvestmentChange = { investment = it.coerceIn(10, 1000) }, ) @@ -590,9 +595,17 @@ private fun ExampleWithScenariosCard( .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val orderedScenarios = scenarios.sortedByDescending { it.isProfit } val changePercents = remember(scenarios.size) { mutableStateListOf().apply { - addAll(scenarios.map { it.initialPercent.coerceIn(0, 10) }) + addAll( + orderedScenarios.map { scenario -> + val nonNegativePercent = scenario.initialPercent.coerceAtLeast(0) + scenario.maxPercent?.let { maxPercent -> + nonNegativePercent.coerceAtMost(maxPercent) + } ?: nonNegativePercent + } + ) } } val directionLabel = stringResource(R.string.Perpetual_Direction) @@ -642,9 +655,9 @@ private fun ExampleWithScenariosCard( GuideNumberAdjuster( valueText = "${leverageValue}x", canDecrease = leverageValue > 0, - canIncrease = leverageValue < 100, + canIncrease = leverageValue < 200, onDecrease = { onLeverageChange((leverageValue - 1).coerceAtLeast(0)) }, - onIncrease = { onLeverageChange((leverageValue + 1).coerceAtMost(100)) }, + onIncrease = { onLeverageChange((leverageValue + 1).coerceAtMost(200)) }, ) } else if (label == investmentLabel && investmentValue != null && onInvestmentChange != null) { GuideNumberAdjuster( @@ -677,28 +690,26 @@ private fun ExampleWithScenariosCard( Spacer(modifier = Modifier.height(16.dp)) - scenarios.forEachIndexed { index, scenario -> + orderedScenarios.forEachIndexed { index, scenario -> if (index > 0) { Spacer(modifier = Modifier.height(16.dp)) } - val scenarioTitle = when (index) { - 0 -> stringResource(R.string.Perpetual_Scenario_1) - 1 -> stringResource(R.string.Perpetual_Scenario_2) - else -> "" - } + val scenarioTitle = stringResource( + R.string.Perpetual_Scenario_Title_Format, + index + 1, + scenario.scenario, + ) Column( modifier = Modifier .fillMaxWidth() ) { - if (scenarioTitle.isNotEmpty()) { - Text( - text = scenarioTitle, - fontSize = 14.sp, - fontWeight = FontWeight.W500, - color = MixinAppTheme.colors.textPrimary - ) - Spacer(modifier = Modifier.height(6.dp)) - } + Text( + text = scenarioTitle, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth()) { Text( @@ -712,6 +723,12 @@ private fun ExampleWithScenariosCard( verticalAlignment = Alignment.CenterVertically, ) { + val maxPercent = scenario.maxPercent + val canIncrease = if (maxPercent == null) { + percent < Int.MAX_VALUE + } else { + percent < maxPercent + } Box( modifier = Modifier .size(24.dp) @@ -742,9 +759,14 @@ private fun ExampleWithScenariosCard( .size(24.dp) .clip(CircleShape) .background(MixinAppTheme.colors.backgroundWindow) - .alpha(if (percent < 10) 1f else 0.5f) - .clickable(enabled = percent < 10) { - changePercents[index] = (percent + 1).coerceAtMost(10) + .alpha(if (canIncrease) 1f else 0.5f) + .clickable(enabled = canIncrease) { + val nextPercent = if (maxPercent == null) { + if (percent == Int.MAX_VALUE) percent else percent + 1 + } else { + (percent + 1).coerceAtMost(maxPercent) + } + changePercents[index] = nextPercent }, contentAlignment = Alignment.Center, ) { @@ -852,7 +874,7 @@ private fun buildOrderValueText( private fun ScenarioData.formatPnl(currentPercent: Int): String { val safeInitialPercent = initialPercent.coerceAtLeast(1) - val safeCurrentPercent = currentPercent.coerceIn(0, 10) + val safeCurrentPercent = currentPercent.coerceAtLeast(0) val amount = (basePnlAmount.toFloat() * safeCurrentPercent / safeInitialPercent).roundToInt() val percent = (basePnlPercent.toFloat() * safeCurrentPercent / safeInitialPercent).roundToInt() val sign = if (isProfit) "+" else "-" 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 6eff8ce77a..bc890b176c 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 @@ -620,6 +620,21 @@ private fun OpenPositionCard( .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) .padding(16.dp) ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.Perpetual_Guide_Position), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween @@ -712,7 +727,7 @@ private fun OpenPositionCard( Column(horizontalAlignment = Alignment.End) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(R.string.Amount), + text = stringResource(R.string.Perpetual_Guide_Position), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) 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 394be87d87..d361406b1b 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 @@ -42,6 +42,7 @@ import one.mixin.android.ui.tip.wc.compose.ItemWalletContent import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal +import java.math.RoundingMode import java.text.SimpleDateFormat import java.util.Locale @@ -85,6 +86,8 @@ fun PositionDetailPage( val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO val absQuantity = quantity.abs() val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + val liquidationPrice = calculateLiquidationPriceValue(entryPrice, position.leverage, isLong) val orderValue = absQuantity * markPrice val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() @@ -155,7 +158,7 @@ fun PositionDetailPage( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(sideColor.copy(alpha = 0.2f)) - .padding(horizontal = 3.dp, vertical = 2.dp) + .padding(horizontal = 8.dp, vertical = 2.dp) .align(Alignment.CenterHorizontally) ) { Text( @@ -238,7 +241,14 @@ fun PositionDetailPage( PositionDetailItem( label = stringResource(R.string.Entry_Price).uppercase(), - value = formatFiat(position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + value = formatFiat(entryPrice) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Liquidation_Price).uppercase(), + value = formatFiat(liquidationPrice) ) Spacer(modifier = Modifier.height(20.dp)) @@ -262,6 +272,25 @@ fun PositionDetailPage( } } +private fun calculateLiquidationPriceValue( + entryPrice: BigDecimal, + leverage: Int, + isLong: Boolean, +): BigDecimal { + if (entryPrice == BigDecimal.ZERO || leverage <= 0) { + return BigDecimal.ZERO + } + + val liquidationPercent = BigDecimal(100.0 / leverage) + val liquidationRatio = liquidationPercent.divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + + return if (isLong) { + entryPrice.multiply(BigDecimal.ONE.subtract(liquidationRatio)) + } else { + entryPrice.multiply(BigDecimal.ONE.add(liquidationRatio)) + } +} + @Composable private fun PositionDetailItem( label: String, @@ -437,7 +466,7 @@ fun PositionDetailPage( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(sideColor.copy(alpha = 0.2f)) - .padding(horizontal = 3.dp, vertical = 2.dp) + .padding(horizontal = 8.dp, vertical = 2.dp) .align(Alignment.CenterHorizontally) ) { Text( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c248d67cf3..90ac28e267 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2261,6 +2261,7 @@ 投入资金 场景一:价格上涨 场景二:价格下跌 + 场景%1$d:%2$s 价格上涨 价格下跌 上涨幅度 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b0ee68906..9977e7816d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2320,6 +2320,7 @@ Investment Scenario 1: Price Rise Scenario 2: Price Fall + Scenario %1$d: %2$s Price Rise Price Fall Upward Change From 0ef0a0e8edeb583aec2cb1c35b79d60cba7e0ced Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 6 Mar 2026 15:31:22 +0800 Subject: [PATCH 051/105] feat(perps): improve deposit flow for USD token and unify empty-state copy --- .../home/web3/trade/perps/OpenPositionPage.kt | 13 ++++++- .../home/web3/trade/perps/PerpetualContent.kt | 39 ++++++++----------- .../ui/home/web3/trade/perps/PerpsActivity.kt | 15 ++++++- .../TokenListBottomSheetDialogFragment.kt | 37 +++++++++++++++--- 4 files changed, 73 insertions(+), 31 deletions(-) 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 4a09830849..ffe6b1d52f 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 @@ -86,6 +86,7 @@ fun OpenPositionPage( onBack: () -> Unit, selectedToken: TokenItem?, onTokenSelect: () -> Unit = {}, + onCurrentTokenChange: (TokenItem?) -> Unit = {}, ) { val context = LocalContext.current val viewModel = hiltViewModel() @@ -140,6 +141,9 @@ fun OpenPositionPage( currentToken = availableTokens.firstOrNull { it.assetId == target.assetId } ?: target } } + LaunchedEffect(currentToken) { + onCurrentTokenChange(currentToken) + } val maxLeverage = currentMarket.leverage.coerceAtLeast(1) LaunchedEffect(usdtAmount, leverage, currentToken?.assetId) { errorInfo = null @@ -159,6 +163,7 @@ fun OpenPositionPage( val tokenBalance = currentToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO val hasInputAmount = inputAmount != null && inputAmount > BigDecimal.ZERO val insufficientBalance = hasInputAmount && inputAmount > tokenBalance + val showAddAction = insufficientBalance || tokenBalance <= BigDecimal.ZERO val canReview = hasInputAmount && !insufficientBalance val displayedErrorInfo = errorInfo?.takeIf { it.isNotBlank() } val tokenNetworkName = currentToken?.chainName @@ -283,7 +288,7 @@ fun OpenPositionPage( usdtAmount = currentToken?.balance ?: "0" } ) - if (insufficientBalance) { + if (showAddAction) { Spacer(modifier = Modifier.width(4.dp)) Text( text = stringResource(R.string.Add), @@ -293,7 +298,11 @@ fun OpenPositionPage( ), modifier = Modifier.clickable { val activity = context as? FragmentActivity ?: return@clickable - val token = currentToken ?: return@clickable + val token = currentToken + if (token == null) { + onTokenSelect() + return@clickable + } AddFeeBottomSheetDialogFragment.newInstance(token) .apply { onAction = { type, addToken -> 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 eaf91fcb6c..7fceb87ebb 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 @@ -393,30 +393,23 @@ fun PerpetualContent( Spacer(modifier = Modifier.height(12.dp)) if (closedPositions.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - contentAlignment = Alignment.Center + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(16.dp)) - Icon( - painter = painterResource(id = R.drawable.ic_empty_transaction), - contentDescription = null, - tint = MixinAppTheme.colors.backgroundGrayLight, - modifier = Modifier.size(78.dp) - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = stringResource(R.string.No_Closed_Positions), - fontSize = 14.sp, - color = MixinAppTheme.colors.textAssist, - ) - Spacer(modifier = Modifier.height(16.dp)) - } + Icon( + painter = painterResource(id = R.drawable.ic_empty_transaction), + contentDescription = null, + tint = MixinAppTheme.colors.backgroundGrayLight, + modifier = Modifier.size(78.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.No_Closed_Positions), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) } } else { closedPositionsPreview.forEach { position -> 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 0262aa734e..f23d64ea0a 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 @@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext import one.mixin.android.session.Session import one.mixin.android.R import one.mixin.android.ui.common.BaseActivity +import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment import one.mixin.android.vo.safe.TokenItem import javax.inject.Inject @@ -116,7 +117,8 @@ class PerpsActivity : BaseActivity() { isLong = isLong, onBack = { finish() }, selectedToken = selectedToken, - onTokenSelect = { showTokenSelection() } + onTokenSelect = { showTokenSelection() }, + onCurrentTokenChange = { token -> selectedToken = token } ) } } @@ -143,9 +145,20 @@ class PerpsActivity : BaseActivity() { currentAssetId = selectedToken?.assetId ).setOnAssetClick { token -> selectedToken = token + }.setOnDepositClick { + showDepositAssetSelection() }.show(supportFragmentManager, TokenListBottomSheetDialogFragment.TAG) } + private fun showDepositAssetSelection() { + val token = selectedToken + if (token == null) { + toast(R.string.Not_found) + return + } + WalletActivity.showDeposit(this, token) + } + private fun refreshPositions() { val walletId = Session.getAccountId() walletId?.let { diff --git a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt index 59e12daf3a..3e71138842 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt @@ -59,6 +59,7 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { const val POS_RV = 0 const val POS_EMPTY_RECEIVE = 1 + const val POS_EMPTY_SEND = 2 const val TYPE_FROM_SEND = 0 const val TYPE_FROM_RECEIVE = 1 @@ -216,6 +217,16 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } } searchEt.setHint(getString(R.string.search_placeholder_asset)) + depositTitle.setOnClickListener { + searchEt.hideKeyboard() + dismiss() + onDeposit?.invoke() + } + depositTv.setOnClickListener { + searchEt.hideKeyboard() + dismiss() + onDeposit?.invoke() + } @SuppressLint("AutoDispose") disposable = @@ -225,8 +236,12 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { .subscribe( { if (it.isNullOrBlank()) { - binding.rvVa.displayedChild = POS_RV adapter.submitList(defaultAssets) + binding.rvVa.displayedChild = if (defaultAssets.isEmpty()) { + emptyStatePosition() + } else { + POS_RV + } } else { if (it.toString() != currentQuery) { currentQuery = it.toString() @@ -250,10 +265,10 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } else { it } - if (fromType == TYPE_FROM_SEND) { + if (fromType == TYPE_FROM_SEND || fromType == TYPE_FROM_PERP) { adapter.submitList(defaultAssets) if (defaultAssets.isEmpty()) { - binding.rvVa.displayedChild = POS_EMPTY_RECEIVE + binding.rvVa.displayedChild = emptyStatePosition() } else { binding.rvVa.displayedChild = POS_RV } @@ -314,7 +329,7 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { private fun loadData() { adapter.chain = currentChain binding.rvVa.displayedChild = when (adapter.getFilteredTokens().size) { - 0 -> POS_EMPTY_RECEIVE + 0 -> if (defaultAssets.isEmpty()) emptyStatePosition() else POS_EMPTY_RECEIVE else -> POS_RV } binding.assetRv.scrollToPosition(0) @@ -365,7 +380,11 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { binding.pb.isVisible = false if (localAssets.isNullOrEmpty() && remoteAssets.isEmpty()) { - binding.rvVa.displayedChild = POS_EMPTY_RECEIVE + binding.rvVa.displayedChild = if (defaultAssets.isEmpty()) { + emptyStatePosition() + } else { + POS_EMPTY_RECEIVE + } } if (!isAdded) return@launch @@ -373,6 +392,14 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } } + private fun emptyStatePosition(): Int { + return if (fromType == TYPE_FROM_SEND || fromType == TYPE_FROM_PERP) { + POS_EMPTY_SEND + } else { + POS_EMPTY_RECEIVE + } + } + fun setOnAssetClick(callback: (TokenItem) -> Unit): TokenListBottomSheetDialogFragment { this.onAsset = callback return this From 976c7753f1fa44b53bd197ab2a1ec094e5fd412a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 6 Mar 2026 16:25:30 +0800 Subject: [PATCH 052/105] fix(perps): include zero-balance USD tokens in TYPE_FROM_PERP list --- .../java/one/mixin/android/db/TokenDao.kt | 2 +- .../web3/trade/perps/PerpetualViewModel.kt | 16 +++++++- .../TokenListBottomSheetDialogFragment.kt | 37 +++---------------- 3 files changed, 21 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/TokenDao.kt b/app/src/main/java/one/mixin/android/db/TokenDao.kt index 130c3513fc..340501ebbf 100644 --- a/app/src/main/java/one/mixin/android/db/TokenDao.kt +++ b/app/src/main/java/one/mixin/android/db/TokenDao.kt @@ -144,7 +144,7 @@ interface TokenDao : BaseDao { fun assetItemsWithBalance(defaultIconUrl: String = Constants.DEFAULT_ICON_URL): LiveData> @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) - @Query("$PREFIX_ASSET_ITEM WHERE ae.balance > 0 AND (ae.hidden IS NULL OR NOT ae.hidden) AND a1.asset_id IN (:usdAssetIds) $POSTFIX_ASSET_ITEM") + @Query("$PREFIX_ASSET_ITEM WHERE (ae.hidden IS NULL OR NOT ae.hidden) AND a1.asset_id IN (:usdAssetIds) $POSTFIX_ASSET_ITEM") fun usdAssetItemsWithBalance(usdAssetIds: List, defaultIconUrl: String = Constants.DEFAULT_ICON_URL): LiveData> @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) 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 f9fb00b403..0201bd8366 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 @@ -26,6 +26,7 @@ import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.db.perps.PerpsPositionHistoryDao import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshPerpsPositionsJob +import one.mixin.android.job.RefreshTokensJob import one.mixin.android.util.ErrorHandler import one.mixin.android.vo.safe.TokenItem import timber.log.Timber @@ -193,7 +194,20 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { - onSuccess(data.filter { it.isNotBlank() }) + val acceptedAssetIds = data + .filter { it.isNotBlank() } + .distinct() + + val missingAssetIds = withContext(Dispatchers.IO) { + val localIds = tokenDao.findTokenItems(acceptedAssetIds).map { it.assetId }.toSet() + acceptedAssetIds.filter { it !in localIds } + } + + missingAssetIds.forEach { assetId -> + jobManager.addJobInBackground(RefreshTokensJob(assetId)) + } + + onSuccess(acceptedAssetIds) } else { val error = "Failed to load accepted assets: ${response.errorDescription}" Timber.e(error) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt index 3e71138842..59e12daf3a 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt @@ -59,7 +59,6 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { const val POS_RV = 0 const val POS_EMPTY_RECEIVE = 1 - const val POS_EMPTY_SEND = 2 const val TYPE_FROM_SEND = 0 const val TYPE_FROM_RECEIVE = 1 @@ -217,16 +216,6 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } } searchEt.setHint(getString(R.string.search_placeholder_asset)) - depositTitle.setOnClickListener { - searchEt.hideKeyboard() - dismiss() - onDeposit?.invoke() - } - depositTv.setOnClickListener { - searchEt.hideKeyboard() - dismiss() - onDeposit?.invoke() - } @SuppressLint("AutoDispose") disposable = @@ -236,12 +225,8 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { .subscribe( { if (it.isNullOrBlank()) { + binding.rvVa.displayedChild = POS_RV adapter.submitList(defaultAssets) - binding.rvVa.displayedChild = if (defaultAssets.isEmpty()) { - emptyStatePosition() - } else { - POS_RV - } } else { if (it.toString() != currentQuery) { currentQuery = it.toString() @@ -265,10 +250,10 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } else { it } - if (fromType == TYPE_FROM_SEND || fromType == TYPE_FROM_PERP) { + if (fromType == TYPE_FROM_SEND) { adapter.submitList(defaultAssets) if (defaultAssets.isEmpty()) { - binding.rvVa.displayedChild = emptyStatePosition() + binding.rvVa.displayedChild = POS_EMPTY_RECEIVE } else { binding.rvVa.displayedChild = POS_RV } @@ -329,7 +314,7 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { private fun loadData() { adapter.chain = currentChain binding.rvVa.displayedChild = when (adapter.getFilteredTokens().size) { - 0 -> if (defaultAssets.isEmpty()) emptyStatePosition() else POS_EMPTY_RECEIVE + 0 -> POS_EMPTY_RECEIVE else -> POS_RV } binding.assetRv.scrollToPosition(0) @@ -380,11 +365,7 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { binding.pb.isVisible = false if (localAssets.isNullOrEmpty() && remoteAssets.isEmpty()) { - binding.rvVa.displayedChild = if (defaultAssets.isEmpty()) { - emptyStatePosition() - } else { - POS_EMPTY_RECEIVE - } + binding.rvVa.displayedChild = POS_EMPTY_RECEIVE } if (!isAdded) return@launch @@ -392,14 +373,6 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } } - private fun emptyStatePosition(): Int { - return if (fromType == TYPE_FROM_SEND || fromType == TYPE_FROM_PERP) { - POS_EMPTY_SEND - } else { - POS_EMPTY_RECEIVE - } - } - fun setOnAssetClick(callback: (TokenItem) -> Unit): TokenListBottomSheetDialogFragment { this.onAsset = callback return this From ef0d1382a4d0ce65d0e4dda60cee95902c31884e Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sun, 8 Mar 2026 22:00:26 +0800 Subject: [PATCH 053/105] some fine-tuning --- .../android/ui/home/web3/trade/TradePage.kt | 2 +- .../web3/trade/perps/PerpetualGuidePage.kt | 137 ++++++++++-------- .../PerpsConfirmBottomSheetDialogFragment.kt | 3 +- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 82 insertions(+), 62 deletions(-) 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 5f8def3483..3716cb08a7 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 @@ -291,7 +291,7 @@ fun TradePage( if (!isPerpetualOrderBadgeDismissed) { onDismissPerpetualOrderBadge() } - onShowAllClosedPositions() + onShowAllOpenPositions() } else { onOrderList(currentWalletId, false) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index a0c73f0f6a..2630bcb1f3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -285,6 +285,9 @@ private fun ShortContent() { @Composable private fun LeverageContent() { + var leverage by remember { mutableIntStateOf(10) } + val basePnlAmount = leverage * 100 + val basePnlPercent = leverage * 10 ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( @@ -299,7 +302,7 @@ private fun LeverageContent() { ), GuideRowData( label = stringResource(R.string.Perpetual_Leverage_Times), - value = "10x" + value = "${leverage}x" ), GuideRowData( label = stringResource(R.string.Perpetual_Investment), @@ -311,20 +314,23 @@ private fun LeverageContent() { scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), initialPercent = 10, - basePnlAmount = 1000, - basePnlPercent = 100, + basePnlAmount = basePnlAmount, + basePnlPercent = basePnlPercent, isProfit = true ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), initialPercent = 10, - basePnlAmount = 1000, - basePnlPercent = 100, + basePnlAmount = basePnlAmount, + basePnlPercent = basePnlPercent, isProfit = false, maxPercent = 100, ) - ) + ), + leverageValue = leverage, + onLeverageChange = { leverage = it.coerceIn(0, 200) }, + isScenarioChangeAdjustable = false, ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithInfoAndRiskCard( @@ -404,6 +410,7 @@ private fun PositionContent() { onLeverageChange = { leverage = it.coerceIn(0, 200) }, investmentValue = investment, onInvestmentChange = { investment = it.coerceIn(10, 1000) }, + isScenarioChangeAdjustable = false, ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithInfoAndRiskCard( @@ -589,6 +596,7 @@ private fun ExampleWithScenariosCard( onLeverageChange: ((Int) -> Unit)? = null, investmentValue: Int? = null, onInvestmentChange: ((Int) -> Unit)? = null, + isScenarioChangeAdjustable: Boolean = true, ) { val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences @@ -723,58 +731,67 @@ private fun ExampleWithScenariosCard( verticalAlignment = Alignment.CenterVertically, ) { - val maxPercent = scenario.maxPercent - val canIncrease = if (maxPercent == null) { - percent < Int.MAX_VALUE - } else { - percent < maxPercent - } - Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(MixinAppTheme.colors.backgroundWindow) - .alpha(if (percent > 0) 1f else 0.5f) - .clickable(enabled = percent > 0) { - changePercents[index] = (percent - 1).coerceAtLeast(0) - }, - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_perps_minus), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(16.dp), + if (isScenarioChangeAdjustable) { + val maxPercent = scenario.maxPercent + val canIncrease = if (maxPercent == null) { + percent < Int.MAX_VALUE + } else { + percent < maxPercent + } + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow) + .alpha(if (percent > 0) 1f else 0.5f) + .clickable(enabled = percent > 0) { + changePercents[index] = (percent - 1).coerceAtLeast(0) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_minus), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = "$percent%", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.padding(horizontal = 8.dp), ) - } - Text( - text = "$percent%", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary, - modifier = Modifier.padding(horizontal = 8.dp), - ) - Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(MixinAppTheme.colors.backgroundWindow) - .alpha(if (canIncrease) 1f else 0.5f) - .clickable(enabled = canIncrease) { - val nextPercent = if (maxPercent == null) { - if (percent == Int.MAX_VALUE) percent else percent + 1 - } else { - (percent + 1).coerceAtMost(maxPercent) - } - changePercents[index] = nextPercent - }, - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_perps_add), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(16.dp), + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow) + .alpha(if (canIncrease) 1f else 0.5f) + .clickable(enabled = canIncrease) { + val nextPercent = if (maxPercent == null) { + if (percent == Int.MAX_VALUE) percent else percent + 1 + } else { + (percent + 1).coerceAtMost(maxPercent) + } + changePercents[index] = nextPercent + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_add), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp), + ) + } + } else { + Text( + text = "$percent%", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary, ) } } @@ -879,7 +896,11 @@ private fun ScenarioData.formatPnl(currentPercent: Int): String { val percent = (basePnlPercent.toFloat() * safeCurrentPercent / safeInitialPercent).roundToInt() val sign = if (isProfit) "+" else "-" val amountText = String.format("%,d", amount) - return "$sign$amountText $pnlAsset ($sign$percent%)" + return if (isProfit) { + "$sign$amountText $pnlAsset ($sign$percent%)" + } else { + "$sign$amountText $pnlAsset" + } } @Composable 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 5b8046fd7d..7a5a7a9de6 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 @@ -401,8 +401,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm PerpsInfoItem( title = stringResource(R.string.Estimated_Liquidation_Price).uppercase(), value = liquidationPrice, - subValue = lossSubValue, - info = true + subValue = lossSubValue ) Box(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9977e7816d..19b742f31a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2298,7 +2298,7 @@ Going short means you expect the price to decrease. If the price goes down, you profit. If it goes up, you lose. Your profit/loss is multiplied by your leverage. Leverage allows you to control a larger position with less capital. For example, with 10x leverage, a 1%% price move results in a 10%% profit or loss. Higher leverage means higher risk. You can close your position at any time to realize your profit or loss. The closing price is based on the current market price. Make sure to monitor your positions to avoid liquidation. - Position + Open Position Instructions Mixin perpetual contracts are derivative trading instruments settled in digital assets, supporting long and short positions with no expiration date. Through leverage, traders can amplify positions to capture trading opportunities from price movements. Product Features From e8075b2334c1c81a9f8958a36f6ffa1092f6ab9f Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sun, 8 Mar 2026 23:08:58 +0800 Subject: [PATCH 054/105] Update candle chart --- .../android/ui/home/web3/trade/CandleChart.kt | 477 +++++++++++------- .../web3/trade/perps/PerpsMarketDetailPage.kt | 2 + .../web3/trade/perps/PositionDetailPage.kt | 4 +- 3 files changed, 306 insertions(+), 177 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index 2d848ff90b..5b88259cf7 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -2,7 +2,8 @@ package one.mixin.android.ui.home.web3.trade import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,27 +29,34 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import one.mixin.android.Constants +import one.mixin.android.api.response.perps.CandleItem import one.mixin.android.api.response.perps.CandleView import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.ui.home.web3.trade.perps.PerpetualViewModel import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlin.math.max import kotlin.math.min @@ -82,7 +90,8 @@ fun CandleChart( Box( modifier = Modifier - .fillMaxSize(), + .fillMaxSize() + .clipToBounds(), contentAlignment = Alignment.Center ) { when { @@ -115,140 +124,189 @@ fun CandleChart( @Composable private fun ScrollableCandleChart(candles: List, context: android.content.Context) { - val items = candles.firstOrNull()?.items ?: emptyList() + val candleView = candles.firstOrNull() ?: return + val items = candleView.items if (items.isEmpty()) return val candleWidth = 6.dp val spacing = 2.dp val density = LocalDensity.current - + val scrollState = rememberScrollState() - var visibleRange by remember { mutableStateOf(Pair(0, items.size)) } - var touchPosition by remember { mutableStateOf(null) } + var touchXOnChart by remember { mutableStateOf(null) } var isTouching by remember { mutableStateOf(false) } - + + val candleStepPx = with(density) { (candleWidth + spacing).toPx() } + val candleWidthPx = with(density) { candleWidth.toPx() } + val chartStartPaddingPx = with(density) { 8.dp.toPx() } + val totalChartWidthPx = with(density) { + (8.dp + (candleWidth * items.size) + (spacing * (items.size - 1).coerceAtLeast(0))).toPx() + } + LaunchedEffect(items.size) { if (items.size > 50) { scrollState.scrollTo(scrollState.maxValue) } } - - LaunchedEffect(scrollState.value, scrollState.maxValue) { - if (items.isNotEmpty()) { - val candleWidthPx = with(density) { (candleWidth + spacing).toPx() } - val containerWidth = with(density) { 200.dp.toPx() } - - val startIndex = (scrollState.value / candleWidthPx).toInt().coerceIn(0, items.size - 1) - val visibleCount = (containerWidth / candleWidthPx).toInt() + 2 - val endIndex = (startIndex + visibleCount).coerceIn(0, items.size) - - visibleRange = Pair(startIndex, endIndex) - } - } - - val visibleItems = items.subList( - visibleRange.first.coerceIn(0, items.size), - visibleRange.second.coerceIn(0, items.size) - ) - - val prices = mutableListOf() - visibleItems.forEach { item -> - item.high.toBigDecimalOrNull()?.let { prices.add(it) } - item.low.toBigDecimalOrNull()?.let { prices.add(it) } - } - val maxPrice = prices.maxOrNull() ?: BigDecimal.ZERO - val minPrice = prices.minOrNull() ?: BigDecimal.ZERO - val avgPrice = (maxPrice + minPrice) / BigDecimal(2) - - val lastItem = items.lastOrNull() - val currentPrice = if (visibleRange.second >= items.size) { - lastItem?.close?.toBigDecimalOrNull() - } else { - null + val selectedIndex = touchXOnChart?.let { x -> + ((x - chartStartPaddingPx) / candleStepPx).toInt().coerceIn(0, items.lastIndex) } + val selectedItem = selectedIndex?.let { index -> items.getOrNull(index) } + val latestPrice = items.lastOrNull()?.close?.toBigDecimalOrNull() + val axisPanelWidth = 52.dp Row(modifier = Modifier.fillMaxSize()) { - Box( + BoxWithConstraints( modifier = Modifier .weight(1f) .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures( - onPress = { offset -> - isTouching = true - touchPosition = offset - tryAwaitRelease() - isTouching = false - touchPosition = null - } - ) - } - .horizontalScroll(scrollState) + .clipToBounds() ) { - PerpsCandleChartCanvas( - candles = candles, - context = context, - candleWidth = candleWidth, - spacing = spacing, - visibleRange = visibleRange, - touchPosition = if (isTouching) touchPosition else null, - scrollOffset = scrollState.value, - maxPrice = maxPrice, - minPrice = minPrice - ) - } + val axisPanelWidthPx = with(density) { axisPanelWidth.toPx() } + val viewportWidthPx = (with(density) { maxWidth.toPx() } - axisPanelWidthPx).coerceAtLeast(1f) + val viewportLeft = scrollState.value.toFloat() + val viewportRight = viewportLeft + viewportWidthPx + val startIndex = ((((viewportLeft - chartStartPaddingPx) - candleWidthPx) / candleStepPx).toInt() - 1) + .coerceAtLeast(0) + val endIndex = ((((viewportRight - chartStartPaddingPx) + candleWidthPx) / candleStepPx).toInt() + 2) + .coerceAtMost(items.size) + val visibleItems = if (startIndex < endIndex) { + items.subList(startIndex, endIndex) + } else { + items + } - BoxWithConstraints( - modifier = Modifier - .fillMaxHeight() - .wrapContentSize() - .padding(start = 4.dp, top = 8.dp, bottom = 8.dp, end = 4.dp) - ) { - val containerHeight = maxHeight - - Column( - modifier = Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween + val prices = mutableListOf() + visibleItems.forEach { item -> + item.high.toBigDecimalOrNull()?.let { prices.add(it) } + item.low.toBigDecimalOrNull()?.let { prices.add(it) } + } + val maxPrice = prices.maxOrNull() ?: BigDecimal.ZERO + val minPrice = prices.minOrNull() ?: BigDecimal.ZERO + val midPrice = (maxPrice + minPrice) / BigDecimal(2) + val maxPriceText = formatPrice(maxPrice) + val midPriceText = formatPrice(midPrice) + val minPriceText = formatPrice(minPrice) + + val selectedPrice = selectedItem?.close?.toBigDecimalOrNull() + val showCurrentPrice = selectedPrice == null && latestPrice != null + val currentPriceText = latestPrice?.let { formatPrice(it) } + val isCurrentPriceInRange = latestPrice?.let { it >= minPrice && it <= maxPrice } == true + val isCurrentPriceOverlapping = currentPriceText != null && + currentPriceText in setOf(maxPriceText, midPriceText, minPriceText) + val showCurrentPriceLine = showCurrentPrice && isCurrentPriceInRange + val showCurrentPriceTag = showCurrentPrice && isCurrentPriceInRange && !isCurrentPriceOverlapping + + Box( + modifier = Modifier + .fillMaxSize() + .padding(end = axisPanelWidth) + .pointerInput(items.size, scrollState.value) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + isTouching = true + touchXOnChart = (offset.x + scrollState.value) + .coerceIn(chartStartPaddingPx, max(totalChartWidthPx, chartStartPaddingPx)) + }, + onDrag = { change, _ -> + touchXOnChart = (change.position.x + scrollState.value) + .coerceIn(chartStartPaddingPx, max(totalChartWidthPx, chartStartPaddingPx)) + change.consume() + }, + onDragEnd = { + isTouching = false + touchXOnChart = null + }, + onDragCancel = { + isTouching = false + touchXOnChart = null + } + ) + } + .horizontalScroll(scrollState, enabled = !isTouching) + .clipToBounds() ) { - Text( - text = formatPrice(maxPrice), - fontSize = 10.sp, - color = MixinAppTheme.colors.textPrimary - ) - Text( - text = formatPrice(avgPrice), - fontSize = 10.sp, - color = MixinAppTheme.colors.textPrimary - ) - Text( - text = formatPrice(minPrice), - fontSize = 10.sp, - color = MixinAppTheme.colors.textPrimary + PerpsCandleChartCanvas( + items = items, + timeFrame = candleView.timeFrame, + context = context, + candleWidth = candleWidth, + spacing = spacing, + touchXOnChart = if (isTouching) touchXOnChart else null, + scrollOffset = scrollState.value.toFloat(), + viewportWidth = viewportWidthPx, + maxPrice = maxPrice, + minPrice = minPrice, + showCurrentPriceLine = showCurrentPriceLine, + currentPriceForLine = latestPrice, + currentPriceLineColor = MixinAppTheme.colors.textPrimary, ) } - if (currentPrice != null) { + BoxWithConstraints( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(axisPanelWidth) + .padding(start = 2.dp, top = 8.dp, bottom = 8.dp, end = 2.dp) + ) { + val containerHeight = maxHeight val priceRange = maxPrice - minPrice - if (priceRange > BigDecimal.ZERO) { - val priceRatio = ((currentPrice - minPrice).toFloat() / priceRange.toFloat()).coerceIn(0f, 1f) - val offsetY = containerHeight * (1f - priceRatio) - + val currentPrice = latestPrice + val currentPriceRatio = if (priceRange > BigDecimal.ZERO && currentPrice != null) { + ((currentPrice - minPrice).toFloat() / priceRange.toFloat()).coerceIn(0f, 1f) + } else { + null + } + + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.End + ) { + Text( + text = maxPriceText, + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.End + ) + Text( + text = midPriceText, + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.End + ) + Text( + text = minPriceText, + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.End + ) + } + + if (showCurrentPriceTag && currentPriceRatio != null) { + val offsetY = containerHeight * (1f - currentPriceRatio) Box( modifier = Modifier - .align(Alignment.TopStart) + .align(Alignment.TopEnd) .offset(y = offsetY) ) { Text( text = formatPrice(currentPrice), fontSize = 10.sp, - color = Color.White, + color = MixinAppTheme.colors.textPrimary, modifier = Modifier .background( - color = Color(0xFF2196F3), - shape = RoundedCornerShape(4.dp) + color = MixinAppTheme.colors.background, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = MixinAppTheme.colors.borderColor, + shape = RoundedCornerShape(6.dp) ) - .padding(horizontal = 4.dp, vertical = 2.dp) + .padding(horizontal = 2.dp, vertical = 1.dp) ) } } @@ -259,15 +317,19 @@ private fun ScrollableCandleChart(candles: List, context: android.co @Composable private fun PerpsCandleChartCanvas( - candles: List, + items: List, + timeFrame: String, context: android.content.Context, candleWidth: androidx.compose.ui.unit.Dp, spacing: androidx.compose.ui.unit.Dp, - visibleRange: Pair, - touchPosition: Offset?, - scrollOffset: Int, + touchXOnChart: Float?, + scrollOffset: Float, + viewportWidth: Float, maxPrice: BigDecimal, - minPrice: BigDecimal + minPrice: BigDecimal, + showCurrentPriceLine: Boolean, + currentPriceForLine: BigDecimal?, + currentPriceLineColor: Color, ) { val quoteColorPref = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) @@ -278,9 +340,8 @@ private fun PerpsCandleChartCanvas( Canvas( modifier = Modifier .fillMaxSize() - .width(((candleWidth + spacing) * (candles.firstOrNull()?.items?.size ?: 0))) + .width(8.dp + (candleWidth * items.size) + (spacing * (items.size - 1).coerceAtLeast(0))) ) { - val items = candles.firstOrNull()?.items ?: emptyList() if (items.isEmpty()) return@Canvas val height = size.height @@ -291,63 +352,73 @@ private fun PerpsCandleChartCanvas( val candleWidthPx = candleWidth.toPx() val spacingPx = spacing.toPx() + val viewportLeft = scrollOffset + val viewportRight = scrollOffset + viewportWidth val priceRange = maxPrice - minPrice if (priceRange == BigDecimal.ZERO) return@Canvas - items.forEachIndexed { index, item -> - val open = item.open.toBigDecimalOrNull() ?: return@forEachIndexed - val close = item.close.toBigDecimalOrNull() ?: return@forEachIndexed - val high = item.high.toBigDecimalOrNull() ?: return@forEachIndexed - val low = item.low.toBigDecimalOrNull() ?: return@forEachIndexed - - val isUp = close >= open - val color = if (isUp) upColor else downColor + clipRect( + left = viewportLeft, + top = paddingTop, + right = viewportRight, + bottom = size.height - paddingBottom + ) { + items.forEachIndexed { index, item -> + val open = item.open.toBigDecimalOrNull() ?: return@forEachIndexed + val close = item.close.toBigDecimalOrNull() ?: return@forEachIndexed + val high = item.high.toBigDecimalOrNull() ?: return@forEachIndexed + val low = item.low.toBigDecimalOrNull() ?: return@forEachIndexed - val x = paddingLeft + index * (candleWidthPx + spacingPx) + candleWidthPx / 2 + val x = paddingLeft + index * (candleWidthPx + spacingPx) + candleWidthPx / 2 + if (x + candleWidthPx < viewportLeft || x - candleWidthPx > viewportRight) { + return@forEachIndexed + } - val highY = paddingTop + chartHeight - ((high - minPrice).toFloat() / priceRange.toFloat() * chartHeight) - val lowY = paddingTop + chartHeight - ((low - minPrice).toFloat() / priceRange.toFloat() * chartHeight) - val openY = paddingTop + chartHeight - ((open - minPrice).toFloat() / priceRange.toFloat() * chartHeight) - val closeY = paddingTop + chartHeight - ((close - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val isUp = close >= open + val color = if (isUp) upColor else downColor + val highY = paddingTop + chartHeight - ((high - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val lowY = paddingTop + chartHeight - ((low - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val openY = paddingTop + chartHeight - ((open - minPrice).toFloat() / priceRange.toFloat() * chartHeight) + val closeY = paddingTop + chartHeight - ((close - minPrice).toFloat() / priceRange.toFloat() * chartHeight) - drawLine( - color = color, - start = Offset(x, highY), - end = Offset(x, lowY), - strokeWidth = 2f - ) + drawLine( + color = color, + start = Offset(x, highY), + end = Offset(x, lowY), + strokeWidth = 2f + ) - val top = min(openY, closeY) - val bottom = max(openY, closeY) - val bodyHeight = max(bottom - top, 2f) + val top = min(openY, closeY) + val bottom = max(openY, closeY) + val bodyHeight = max(bottom - top, 2f) - drawRoundRect( - color = color, - topLeft = Offset(x - candleWidthPx / 2, top), - size = Size(candleWidthPx, bodyHeight), - cornerRadius = CornerRadius(1f, 1f) - ) - } - - val lastItem = items.lastOrNull() - if (lastItem != null && visibleRange.second >= items.size) { - val lastClose = lastItem.close.toBigDecimalOrNull() - if (lastClose != null) { - drawCurrentPriceLine( - price = lastClose, - minPrice = minPrice, - priceRange = priceRange, - paddingTop = paddingTop, - chartHeight = chartHeight, - paddingLeft = paddingLeft + drawRoundRect( + color = color, + topLeft = Offset(x - candleWidthPx / 2, top), + size = Size(candleWidthPx, bodyHeight), + cornerRadius = CornerRadius(1f, 1f) ) } } - touchPosition?.let { touch -> - val adjustedX = touch.x + scrollOffset - val candleIndex = ((adjustedX - paddingLeft) / (candleWidthPx + spacingPx)).toInt() + if (showCurrentPriceLine && currentPriceForLine != null) { + drawCurrentPriceLine( + price = currentPriceForLine, + minPrice = minPrice, + priceRange = priceRange, + paddingTop = paddingTop, + chartHeight = chartHeight, + paddingLeft = paddingLeft, + viewportLeft = viewportLeft, + viewportRight = viewportRight, + paddingBottom = paddingBottom, + lineColor = currentPriceLineColor, + ) + } + + touchXOnChart?.let { selectedX -> + val candleIndex = ((selectedX - paddingLeft) / (candleWidthPx + spacingPx)).toInt() if (candleIndex in items.indices) { val item = items[candleIndex] val close = item.close.toBigDecimalOrNull() @@ -357,11 +428,13 @@ private fun PerpsCandleChartCanvas( x = paddingLeft + candleIndex * (candleWidthPx + spacingPx) + candleWidthPx / 2, y = priceY, price = close, - width = size.width, + timeText = formatCandleTime(item.timestamp, timeFrame), paddingTop = paddingTop, paddingBottom = paddingBottom, paddingLeft = paddingLeft, - textMeasurer = textMeasurer + textMeasurer = textMeasurer, + viewportLeft = viewportLeft, + viewportRight = viewportRight, ) } } @@ -375,14 +448,19 @@ private fun DrawScope.drawCurrentPriceLine( priceRange: BigDecimal, paddingTop: Float, chartHeight: Float, - paddingLeft: Float + paddingLeft: Float, + viewportLeft: Float, + viewportRight: Float, + paddingBottom: Float, + lineColor: Color, ) { - val y = paddingTop + chartHeight - ((price - minPrice).toFloat() / priceRange.toFloat() * chartHeight) - + val y = (paddingTop + chartHeight - ((price - minPrice).toFloat() / priceRange.toFloat() * chartHeight)) + .coerceIn(paddingTop, size.height - paddingBottom) + drawLine( - color = Color(0xFF2196F3), - start = Offset(paddingLeft, y), - end = Offset(size.width, y), + color = lineColor, + start = Offset(max(paddingLeft, viewportLeft), y), + end = Offset(min(size.width, viewportRight), y), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) ) @@ -392,22 +470,24 @@ private fun DrawScope.drawTouchCrosshair( x: Float, y: Float, price: BigDecimal, - width: Float, + timeText: String, paddingTop: Float, paddingBottom: Float, paddingLeft: Float, - textMeasurer: androidx.compose.ui.text.TextMeasurer + textMeasurer: androidx.compose.ui.text.TextMeasurer, + viewportLeft: Float, + viewportRight: Float, ) { val lineColor = Color(0xFF9E9E9E) - + drawLine( color = lineColor, start = Offset(paddingLeft, y), - end = Offset(width, y), + end = Offset(size.width, y), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) ) - + drawLine( color = lineColor, start = Offset(x, paddingTop), @@ -415,23 +495,56 @@ private fun DrawScope.drawTouchCrosshair( strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) ) - + val priceText = formatPrice(price) - val textLayoutResult = textMeasurer.measure( + val priceTextLayout = textMeasurer.measure( text = priceText, - style = TextStyle(fontSize = 10.sp, color = Color.White) + style = TextStyle(fontSize = 11.sp, color = Color.White) ) - + val priceTagHorizontalPadding = 10f + val priceTagVerticalPadding = 5f + val priceTagWidth = priceTextLayout.size.width + priceTagHorizontalPadding * 2 + val priceTagHeight = priceTextLayout.size.height + priceTagVerticalPadding * 2 + val priceTagMinX = viewportLeft + 4f + val priceTagMaxX = max(priceTagMinX, viewportRight - priceTagWidth - 4f) + val priceTagX = (viewportRight - priceTagWidth - 4f).coerceIn(priceTagMinX, priceTagMaxX) + val priceTagY = (y - priceTagHeight / 2).coerceIn(paddingTop, size.height - paddingBottom - priceTagHeight) + drawRoundRect( - color = Color(0xFF2196F3), - topLeft = Offset(width - textLayoutResult.size.width - 8f, y - textLayoutResult.size.height / 2 - 2f), - size = Size(textLayoutResult.size.width + 8f, textLayoutResult.size.height + 4f), - cornerRadius = CornerRadius(4f, 4f) + color = Color(0xFF1F2533), + topLeft = Offset(priceTagX, priceTagY), + size = Size(priceTagWidth, priceTagHeight), + cornerRadius = CornerRadius(12f, 12f) ) - + drawText( - textLayoutResult = textLayoutResult, - topLeft = Offset(width - textLayoutResult.size.width - 4f, y - textLayoutResult.size.height / 2) + textLayoutResult = priceTextLayout, + topLeft = Offset(priceTagX + priceTagHorizontalPadding, priceTagY + priceTagVerticalPadding) + ) + + val timeTextLayout = textMeasurer.measure( + text = timeText, + style = TextStyle(fontSize = 11.sp, color = Color.White) + ) + val timeTagHorizontalPadding = 10f + val timeTagVerticalPadding = 5f + val timeTagWidth = timeTextLayout.size.width + timeTagHorizontalPadding * 2 + val timeTagHeight = timeTextLayout.size.height + timeTagVerticalPadding * 2 + val timeTagMinX = viewportLeft + 4f + val timeTagMaxX = max(timeTagMinX, viewportRight - timeTagWidth - 4f) + val timeTagX = (x - timeTagWidth / 2).coerceIn(timeTagMinX, timeTagMaxX) + val timeTagY = paddingTop + 2f + + drawRoundRect( + color = Color(0xFF1F2533), + topLeft = Offset(timeTagX, timeTagY), + size = Size(timeTagWidth, timeTagHeight), + cornerRadius = CornerRadius(12f, 12f) + ) + + drawText( + textLayoutResult = timeTextLayout, + topLeft = Offset(timeTagX + timeTagHorizontalPadding, timeTagY + timeTagVerticalPadding) ) } @@ -443,6 +556,20 @@ private fun formatPrice(price: BigDecimal): String { } } +private fun formatCandleTime(timestamp: Long, timeFrame: String): String { + val millis = if (timestamp < 1_000_000_000_000L) timestamp * 1000 else timestamp + val pattern = when (timeFrame.lowercase()) { + "1h" -> "MM-dd HH:mm" + "1d" -> "yyyy-MM-dd HH:mm" + "1w" -> "yyyy-MM-dd" + "1m" -> "yyyy-MM" + else -> "MM-dd HH:mm" + } + return runCatching { + SimpleDateFormat(pattern, Locale.getDefault()).format(Date(millis)) + }.getOrDefault("--") +} + private fun String.toBigDecimalOrNull(): BigDecimal? { return try { BigDecimal(this) 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 bc890b176c..d819052e91 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 @@ -35,6 +35,7 @@ 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.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -542,6 +543,7 @@ private fun MarketDetailCard( modifier = Modifier .fillMaxWidth() .height(180.dp) + .clipToBounds() ) { CandleChart( symbol = marketSymbol, 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 d361406b1b..36fe856c80 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 @@ -158,7 +158,7 @@ fun PositionDetailPage( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(sideColor.copy(alpha = 0.2f)) - .padding(horizontal = 8.dp, vertical = 2.dp) + .padding(horizontal = 8.dp, vertical = 2.5.dp) .align(Alignment.CenterHorizontally) ) { Text( @@ -466,7 +466,7 @@ fun PositionDetailPage( modifier = Modifier .clip(RoundedCornerShape(8.dp)) .background(sideColor.copy(alpha = 0.2f)) - .padding(horizontal = 8.dp, vertical = 2.dp) + .padding(horizontal = 8.dp, vertical = 2.5.dp) .align(Alignment.CenterHorizontally) ) { Text( From ad438ceff1bee651bfce9713452710bbe20178ce Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Mar 2026 13:19:49 +0800 Subject: [PATCH 055/105] Update closed pnl --- .../web3/trade/perps/PositionDetailPage.kt | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt index 36fe856c80..9c4b4c3d52 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 @@ -398,6 +398,7 @@ fun PositionDetailPage( val orderValue = absQuantity * closePrice val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() + val currencyCode = Fiats.getAccountCurrencyAppearance() fun formatFiat(value: BigDecimal): String { return "$fiatSymbol${value.multiply(fiatRate).priceFormat()}" @@ -410,6 +411,15 @@ fun PositionDetailPage( else -> formatFiat(BigDecimal.ZERO) } } + + fun formatPnlAmount(value: BigDecimal): String { + val convertedValue = value.multiply(fiatRate) + return when { + value > BigDecimal.ZERO -> "+${convertedValue.priceFormat()}" + value < BigDecimal.ZERO -> convertedValue.priceFormat() + else -> convertedValue.priceFormat() + } + } PageScaffold( title = title, @@ -452,13 +462,25 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) - Text( - text = formatSignedFiat(pnl), - fontSize = 24.sp, - fontWeight = FontWeight.W500, - color = pnlColor, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) + Row( + modifier = Modifier.align(Alignment.CenterHorizontally), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = formatPnlAmount(pnl), + fontSize = 34.sp, + fontWeight = FontWeight.W500, + color = pnlColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = currencyCode, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.padding(bottom = 4.dp) + ) + } Spacer(modifier = Modifier.height(10.dp)) From 0119276173920da06164db5a949154f30444f202 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Mar 2026 13:23:12 +0800 Subject: [PATCH 056/105] Update font --- .../android/ui/home/web3/trade/perps/PositionDetailPage.kt | 3 +++ 1 file changed, 3 insertions(+) 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 9c4b4c3d52..1eae02a1d4 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 @@ -28,6 +28,8 @@ 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.Font +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -470,6 +472,7 @@ fun PositionDetailPage( text = formatPnlAmount(pnl), fontSize = 34.sp, fontWeight = FontWeight.W500, + fontFamily = FontFamily(Font(R.font.mixin_font)), color = pnlColor ) Spacer(modifier = Modifier.width(4.dp)) From 2cd8381b5620d314c2d449abc81ca2f89be7fc09 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Mar 2026 15:52:27 +0800 Subject: [PATCH 057/105] Update refresh logic --- .../android/job/RefreshPerpsPositionsJob.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt index a535232c3b..af4e8f5ab5 100644 --- a/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt +++ b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt @@ -21,16 +21,15 @@ class RefreshPerpsPositionsJob( override fun onRun(): Unit = runBlocking { val perpsDb = PerpsDatabase.getDatabase(applicationContext) val positionDao = perpsDb.perpsPositionDao() - val marketDao = perpsDb.perpsMarketDao() if (walletId != null) { - refreshPositions(walletId, positionDao, marketDao) + refreshPositions(walletId, positionDao) } else { val wallets = web3WalletDao.getAllWallets().filter { !it.isWatch() }.map { it.id }.toMutableSet() Session.getAccountId()?.let { wallets.add(it) } wallets.forEach { wId -> - refreshPositions(wId, positionDao, marketDao) + refreshPositions(wId, positionDao) } } } @@ -38,7 +37,6 @@ class RefreshPerpsPositionsJob( private suspend fun refreshPositions( walletId: String, positionDao: PerpsPositionDao, - marketDao: PerpsMarketDao ) { try { val response = routeService.getPerpsPositions(walletId = walletId) @@ -47,10 +45,17 @@ class RefreshPerpsPositionsJob( val positions = response.data!!.map { it.copy(walletId = walletId) } Timber.d("RefreshPerpsPositionsJob: Fetched ${positions.size} positions for wallet $walletId") - if (positions.isNotEmpty()) { - positionDao.insertAll(positions) - } else { - Timber.d("RefreshPerpsPositionsJob: Keep local positions when remote list is empty for wallet $walletId") + val perpsDb = PerpsDatabase.getDatabase(applicationContext) + perpsDb.runInTransaction { + runBlocking { + if (positions.isEmpty()) { + positionDao.deleteOpenByWallet(walletId) + } else { + val positionIds = positions.map { it.positionId } + positionDao.deleteOpenByWalletAndNotIn(walletId, positionIds) + positionDao.insertAll(positions) + } + } } } else { Timber.e("RefreshPerpsPositionsJob: Failed to fetch positions for wallet $walletId: ${response.errorDescription}") From 5b75cc7cfdf9031befb43c96f125883b547c2c03 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Mar 2026 16:46:59 +0800 Subject: [PATCH 058/105] Update guide page --- .../web3/trade/perps/PerpetualGuidePage.kt | 125 +++++++++++------- 1 file changed, 74 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 2630bcb1f3..87c0b0cfb7 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -58,12 +58,12 @@ import one.mixin.android.widget.components.DotText data class ScenarioData( val scenario: String, val change: String, - val initialPercent: Int = 10, + val initialPercent: Float = 10f, val basePnlAmount: Int, val basePnlPercent: Int, val pnlAsset: String = "USDT", val isProfit: Boolean, - val maxPercent: Int? = null, + val maxPercent: Float? = null, ) data class GuideRowData( @@ -179,6 +179,8 @@ private fun OverviewContent() { @Composable private fun LongContent() { + val leverage = 10 + val maxLossPercent = 100f / leverage ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( @@ -193,7 +195,7 @@ private fun LongContent() { ), GuideRowData( label = stringResource(R.string.Perpetual_Leverage_Times), - value = "10x" + value = "${leverage}x" ), GuideRowData( label = stringResource(R.string.Perpetual_Investment), @@ -204,19 +206,20 @@ private fun LongContent() { ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), - initialPercent = 10, - basePnlAmount = 100, - basePnlPercent = 10, - isProfit = true + initialPercent = maxLossPercent, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = true, + maxPercent = null ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), - initialPercent = 10, - basePnlAmount = 100, - basePnlPercent = 10, + initialPercent = maxLossPercent, + basePnlAmount = 1000, + basePnlPercent = 100, isProfit = false, - maxPercent = 100, + maxPercent = maxLossPercent, ) ) ) @@ -232,6 +235,8 @@ private fun LongContent() { @Composable private fun ShortContent() { + val leverage = 10 + val maxLossPercent = 100f / leverage ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( @@ -246,7 +251,7 @@ private fun ShortContent() { ), GuideRowData( label = stringResource(R.string.Perpetual_Leverage_Times), - value = "10x" + value = "${leverage}x" ), GuideRowData( label = stringResource(R.string.Perpetual_Investment), @@ -257,19 +262,20 @@ private fun ShortContent() { ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), - initialPercent = 10, - basePnlAmount = 100, - basePnlPercent = 10, + initialPercent = maxLossPercent, + basePnlAmount = 1000, + basePnlPercent = 100, isProfit = true, - maxPercent = 100, + maxPercent = null, ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), - initialPercent = 10, - basePnlAmount = 100, - basePnlPercent = 10, - isProfit = false + initialPercent = maxLossPercent, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = false, + maxPercent = maxLossPercent, ) ) ) @@ -286,6 +292,7 @@ private fun ShortContent() { @Composable private fun LeverageContent() { var leverage by remember { mutableIntStateOf(10) } + val maxLossPercent = if (leverage > 0) 100f / leverage else 100f val basePnlAmount = leverage * 100 val basePnlPercent = leverage * 10 ExampleWithScenariosCard( @@ -313,23 +320,24 @@ private fun LeverageContent() { ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), - initialPercent = 10, + initialPercent = maxLossPercent, basePnlAmount = basePnlAmount, basePnlPercent = basePnlPercent, - isProfit = true + isProfit = true, + maxPercent = null ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), - initialPercent = 10, + initialPercent = maxLossPercent, basePnlAmount = basePnlAmount, basePnlPercent = basePnlPercent, isProfit = false, - maxPercent = 100, + maxPercent = maxLossPercent, ) ), leverageValue = leverage, - onLeverageChange = { leverage = it.coerceIn(0, 200) }, + onLeverageChange = { leverage = it.coerceIn(1, 200) }, isScenarioChangeAdjustable = false, ) Spacer(modifier = Modifier.height(16.dp)) @@ -346,6 +354,7 @@ private fun PositionContent() { val viewModel = hiltViewModel() var leverage by remember { mutableIntStateOf(10) } var investment by remember { mutableIntStateOf(1000) } + val maxLossPercent = if (leverage > 0) 100f / leverage else 100f val solToken by remember { viewModel.observeTokenByChainAndSymbol( chainId = Constants.ChainId.Solana, @@ -359,8 +368,8 @@ private fun PositionContent() { orderValueUsdt = orderValueUsdt, localSolPrice = localSolPrice ) - val basePnlAmount = orderValueUsdt / 10 - val basePnlPercent = leverage * 10 + val basePnlAmount = (orderValueUsdt * maxLossPercent / 100).toInt() + val basePnlPercent = (leverage * maxLossPercent).toInt() ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), @@ -391,26 +400,28 @@ private fun PositionContent() { ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), - initialPercent = 10, + initialPercent = maxLossPercent, basePnlAmount = basePnlAmount, basePnlPercent = basePnlPercent, - isProfit = true + isProfit = true, + maxPercent = null ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), - initialPercent = 10, + initialPercent = maxLossPercent, basePnlAmount = basePnlAmount, basePnlPercent = basePnlPercent, isProfit = false, - maxPercent = 100, + maxPercent = maxLossPercent, ) ), leverageValue = leverage, - onLeverageChange = { leverage = it.coerceIn(0, 200) }, + onLeverageChange = { leverage = it.coerceIn(1, 100) }, investmentValue = investment, onInvestmentChange = { investment = it.coerceIn(10, 1000) }, isScenarioChangeAdjustable = false, + maxLeverage = 100, ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithInfoAndRiskCard( @@ -597,6 +608,7 @@ private fun ExampleWithScenariosCard( investmentValue: Int? = null, onInvestmentChange: ((Int) -> Unit)? = null, isScenarioChangeAdjustable: Boolean = true, + maxLeverage: Int = 200, ) { val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences @@ -604,11 +616,11 @@ private fun ExampleWithScenariosCard( val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed val orderedScenarios = scenarios.sortedByDescending { it.isProfit } - val changePercents = remember(scenarios.size) { - mutableStateListOf().apply { + val changePercents = remember(scenarios.hashCode(), leverageValue, investmentValue) { + mutableStateListOf().apply { addAll( orderedScenarios.map { scenario -> - val nonNegativePercent = scenario.initialPercent.coerceAtLeast(0) + val nonNegativePercent = scenario.initialPercent.coerceAtLeast(0f) scenario.maxPercent?.let { maxPercent -> nonNegativePercent.coerceAtMost(maxPercent) } ?: nonNegativePercent @@ -624,7 +636,6 @@ private fun ExampleWithScenariosCard( Column( modifier = Modifier .fillMaxWidth() - .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) .padding(16.dp) ) { @@ -662,10 +673,10 @@ private fun ExampleWithScenariosCard( } else if (label == leverageLabel && leverageValue != null && onLeverageChange != null) { GuideNumberAdjuster( valueText = "${leverageValue}x", - canDecrease = leverageValue > 0, - canIncrease = leverageValue < 200, - onDecrease = { onLeverageChange((leverageValue - 1).coerceAtLeast(0)) }, - onIncrease = { onLeverageChange((leverageValue + 1).coerceAtMost(200)) }, + canDecrease = leverageValue > 1, + canIncrease = leverageValue < maxLeverage, + onDecrease = { onLeverageChange((leverageValue - 1).coerceAtLeast(1)) }, + onIncrease = { onLeverageChange((leverageValue + 1).coerceAtMost(maxLeverage)) }, ) } else if (label == investmentLabel && investmentValue != null && onInvestmentChange != null) { GuideNumberAdjuster( @@ -733,8 +744,10 @@ private fun ExampleWithScenariosCard( { if (isScenarioChangeAdjustable) { val maxPercent = scenario.maxPercent + val step = if (percent < 1f) 0.1f else 1f + val canDecrease = percent > 0f val canIncrease = if (maxPercent == null) { - percent < Int.MAX_VALUE + true } else { percent < maxPercent } @@ -743,9 +756,9 @@ private fun ExampleWithScenariosCard( .size(24.dp) .clip(CircleShape) .background(MixinAppTheme.colors.backgroundWindow) - .alpha(if (percent > 0) 1f else 0.5f) - .clickable(enabled = percent > 0) { - changePercents[index] = (percent - 1).coerceAtLeast(0) + .alpha(if (canDecrease) 1f else 0.5f) + .clickable(enabled = canDecrease) { + changePercents[index] = (percent - step).coerceAtLeast(0f) }, contentAlignment = Alignment.Center, ) { @@ -757,7 +770,7 @@ private fun ExampleWithScenariosCard( ) } Text( - text = "$percent%", + text = formatPercent(percent), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary, @@ -771,9 +784,9 @@ private fun ExampleWithScenariosCard( .alpha(if (canIncrease) 1f else 0.5f) .clickable(enabled = canIncrease) { val nextPercent = if (maxPercent == null) { - if (percent == Int.MAX_VALUE) percent else percent + 1 + percent + step } else { - (percent + 1).coerceAtMost(maxPercent) + (percent + step).coerceAtMost(maxPercent) } changePercents[index] = nextPercent }, @@ -788,7 +801,7 @@ private fun ExampleWithScenariosCard( } } else { Text( - text = "$percent%", + text = formatPercent(percent), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary, @@ -889,9 +902,19 @@ private fun buildOrderValueText( return "$usdtText ($solAmount SOL)" } -private fun ScenarioData.formatPnl(currentPercent: Int): String { - val safeInitialPercent = initialPercent.coerceAtLeast(1) - val safeCurrentPercent = currentPercent.coerceAtLeast(0) +private fun formatPercent(percent: Float): String { + return if (percent % 1 == 0f) { + "${percent.toInt()}%" + } else { + val formatted = String.format("%.2f", percent) + val trimmed = formatted.trimEnd('0').trimEnd('.') + "$trimmed%" + } +} + +private fun ScenarioData.formatPnl(currentPercent: Float): String { + val safeInitialPercent = initialPercent.coerceAtLeast(0.01f) + val safeCurrentPercent = currentPercent.coerceAtLeast(0f) val amount = (basePnlAmount.toFloat() * safeCurrentPercent / safeInitialPercent).roundToInt() val percent = (basePnlPercent.toFloat() * safeCurrentPercent / safeInitialPercent).roundToInt() val sign = if (isProfit) "+" else "-" From e4ed47d24c687f027470a8ce1f90df1b6d9a8f86 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Mar 2026 17:01:29 +0800 Subject: [PATCH 059/105] Update candle chart --- .../android/ui/home/web3/trade/CandleChart.kt | 182 ++++++++++-------- 1 file changed, 107 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index 5b88259cf7..ce94945f7b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset @@ -47,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.google.protobuf.Mixin import one.mixin.android.Constants import one.mixin.android.api.response.perps.CandleItem import one.mixin.android.api.response.perps.CandleView @@ -241,6 +243,9 @@ private fun ScrollableCandleChart(candles: List, context: android.co showCurrentPriceLine = showCurrentPriceLine, currentPriceForLine = latestPrice, currentPriceLineColor = MixinAppTheme.colors.textPrimary, + crosshairLineColor = MixinAppTheme.colors.textAssist, + crosshairTagBackgroundColor = MixinAppTheme.colors.background, + crosshairTextColor = MixinAppTheme.colors.textPrimary, ) } @@ -249,7 +254,7 @@ private fun ScrollableCandleChart(candles: List, context: android.co .align(Alignment.CenterEnd) .fillMaxHeight() .width(axisPanelWidth) - .padding(start = 2.dp, top = 8.dp, bottom = 8.dp, end = 2.dp) + .padding(start = 0.dp, top = 8.dp, bottom = 8.dp, end = 2.dp) ) { val containerHeight = maxHeight val priceRange = maxPrice - minPrice @@ -260,55 +265,103 @@ private fun ScrollableCandleChart(candles: List, context: android.co null } - Column( - modifier = Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.End - ) { - Text( - text = maxPriceText, - fontSize = 10.sp, - color = MixinAppTheme.colors.textPrimary, - textAlign = TextAlign.End - ) - Text( - text = midPriceText, - fontSize = 10.sp, - color = MixinAppTheme.colors.textPrimary, - textAlign = TextAlign.End - ) - Text( - text = minPriceText, - fontSize = 10.sp, - color = MixinAppTheme.colors.textPrimary, - textAlign = TextAlign.End - ) - } - - if (showCurrentPriceTag && currentPriceRatio != null) { - val offsetY = containerHeight * (1f - currentPriceRatio) - Box( + if (!isTouching) { + Column( modifier = Modifier - .align(Alignment.TopEnd) - .offset(y = offsetY) + .fillMaxHeight() + .align(Alignment.CenterEnd), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.End ) { Text( - text = formatPrice(currentPrice), + text = maxPriceText, fontSize = 10.sp, color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.End, + modifier = Modifier + .padding(horizontal = 2.dp, vertical = 1.dp) + ) + Text( + text = midPriceText, + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.End, + modifier = Modifier + .padding(horizontal = 2.dp, vertical = 1.dp) + ) + Text( + text = minPriceText, + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.End, modifier = Modifier - .background( - color = MixinAppTheme.colors.background, - shape = RoundedCornerShape(6.dp) - ) - .border( - width = 1.dp, - color = MixinAppTheme.colors.borderColor, - shape = RoundedCornerShape(6.dp) - ) .padding(horizontal = 2.dp, vertical = 1.dp) ) } + + if (showCurrentPriceTag && currentPriceRatio != null) { + val offsetY = containerHeight * (1f - currentPriceRatio) + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(y = offsetY), + contentAlignment = Alignment.CenterEnd + ) { + Text( + text = formatPrice(currentPrice), + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.End, + modifier = Modifier + .background( + color = MixinAppTheme.colors.background, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = MixinAppTheme.colors.borderColor, + shape = RoundedCornerShape(6.dp) + ) + .padding(horizontal = 2.dp, vertical = 1.dp) + ) + } + } + } else { + selectedItem?.close?.toBigDecimalOrNull()?.let { selectedPrice -> + val selectedPriceRatio = if (priceRange > BigDecimal.ZERO) { + ((selectedPrice - minPrice).toFloat() / priceRange.toFloat()).coerceIn(0f, 1f) + } else { + null + } + + selectedPriceRatio?.let { ratio -> + val offsetY = containerHeight * (1f - ratio) + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(y = offsetY), + contentAlignment = Alignment.CenterEnd + ) { + Text( + text = formatPrice(selectedPrice), + fontSize = 10.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.End, + modifier = Modifier + .background( + color = MixinAppTheme.colors.background, + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = MixinAppTheme.colors.borderColor, + shape = RoundedCornerShape(6.dp) + ) + .padding(horizontal = 2.dp, vertical = 1.dp) + ) + } + } + } } } } @@ -330,6 +383,9 @@ private fun PerpsCandleChartCanvas( showCurrentPriceLine: Boolean, currentPriceForLine: BigDecimal?, currentPriceLineColor: Color, + crosshairLineColor: Color, + crosshairTagBackgroundColor: Color, + crosshairTextColor: Color, ) { val quoteColorPref = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) @@ -435,6 +491,9 @@ private fun PerpsCandleChartCanvas( textMeasurer = textMeasurer, viewportLeft = viewportLeft, viewportRight = viewportRight, + lineColor = crosshairLineColor, + tagBackgroundColor = crosshairTagBackgroundColor, + textColor = crosshairTextColor, ) } } @@ -477,54 +536,27 @@ private fun DrawScope.drawTouchCrosshair( textMeasurer: androidx.compose.ui.text.TextMeasurer, viewportLeft: Float, viewportRight: Float, + lineColor: Color, + tagBackgroundColor: Color, + textColor: Color, ) { - val lineColor = Color(0xFF9E9E9E) - drawLine( color = lineColor, start = Offset(paddingLeft, y), end = Offset(size.width, y), - strokeWidth = 1f, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) + strokeWidth = 1f ) drawLine( color = lineColor, start = Offset(x, paddingTop), end = Offset(x, size.height - paddingBottom), - strokeWidth = 1f, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) - ) - - val priceText = formatPrice(price) - val priceTextLayout = textMeasurer.measure( - text = priceText, - style = TextStyle(fontSize = 11.sp, color = Color.White) - ) - val priceTagHorizontalPadding = 10f - val priceTagVerticalPadding = 5f - val priceTagWidth = priceTextLayout.size.width + priceTagHorizontalPadding * 2 - val priceTagHeight = priceTextLayout.size.height + priceTagVerticalPadding * 2 - val priceTagMinX = viewportLeft + 4f - val priceTagMaxX = max(priceTagMinX, viewportRight - priceTagWidth - 4f) - val priceTagX = (viewportRight - priceTagWidth - 4f).coerceIn(priceTagMinX, priceTagMaxX) - val priceTagY = (y - priceTagHeight / 2).coerceIn(paddingTop, size.height - paddingBottom - priceTagHeight) - - drawRoundRect( - color = Color(0xFF1F2533), - topLeft = Offset(priceTagX, priceTagY), - size = Size(priceTagWidth, priceTagHeight), - cornerRadius = CornerRadius(12f, 12f) - ) - - drawText( - textLayoutResult = priceTextLayout, - topLeft = Offset(priceTagX + priceTagHorizontalPadding, priceTagY + priceTagVerticalPadding) + strokeWidth = 1f ) val timeTextLayout = textMeasurer.measure( text = timeText, - style = TextStyle(fontSize = 11.sp, color = Color.White) + style = TextStyle(fontSize = 11.sp, color = textColor) ) val timeTagHorizontalPadding = 10f val timeTagVerticalPadding = 5f @@ -536,7 +568,7 @@ private fun DrawScope.drawTouchCrosshair( val timeTagY = paddingTop + 2f drawRoundRect( - color = Color(0xFF1F2533), + color = tagBackgroundColor, topLeft = Offset(timeTagX, timeTagY), size = Size(timeTagWidth, timeTagHeight), cornerRadius = CornerRadius(12f, 12f) From f05e20ed4a9bf7b4f72d5bb1877031c0823f0f83 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Mar 2026 17:15:55 +0800 Subject: [PATCH 060/105] Update closed item --- .../home/web3/trade/ClosedPositionAdapter.kt | 18 ++++++++++++++++- .../ui/home/web3/trade/ClosedPositionItem.kt | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt index d3df79bce2..574d24dc56 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt @@ -69,10 +69,26 @@ class ClosedPositionAdapter( } else { context.getString(R.string.Short) } + val sideColor = context.getColor( + if (isLong) { + if (isQuoteColorReversed) R.color.wallet_red else R.color.wallet_green + } else { + if (isQuoteColorReversed) R.color.wallet_green else R.color.wallet_red + } + ) val displaySymbol = position.tokenSymbol ?: context.getString(R.string.Unknown) titleTv.text = context.getString(R.string.Perpetual_Side_Symbol_Title, sideText, displaySymbol) - leverageTv.isVisible = false + leverageTv.isVisible = true + leverageTv.text = "${position.leverage}x" + leverageTv.setTextColor(sideColor) + leverageTv.setBackgroundResource( + if (isLong) { + if (isQuoteColorReversed) R.drawable.bg_perps_leverage_short else R.drawable.bg_perps_leverage_long + } else { + if (isQuoteColorReversed) R.drawable.bg_perps_leverage_long else R.drawable.bg_perps_leverage_short + } + ) val quantityStr = position.quantity quantityTv.text = "$quantityStr ${position.tokenSymbol ?: ""}" diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index 8fba88dfeb..1af49e07a1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3.trade +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -74,6 +75,14 @@ fun ClosedPositionItem( position.quantity } + val isLong = position.side.equals("long", true) + val sideColor = if (isLong) { + if (quoteColorPref) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + } else { + if (quoteColorPref) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + } + val leverageBackgroundColor = sideColor.copy(alpha = 0.1f) + Row( modifier = Modifier .fillMaxWidth() @@ -116,6 +125,17 @@ fun ClosedPositionItem( fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "${position.leverage}x", + fontSize = 12.sp, + color = sideColor, + lineHeight = 14.sp, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(leverageBackgroundColor) + .padding(horizontal = 3.dp, vertical = 2.dp) + ) } Spacer(modifier = Modifier.height(4.dp)) Text( From 87acfe6d637e298e522dcd0d27e53577d0eba09a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Mar 2026 19:31:40 +0800 Subject: [PATCH 061/105] Update guide and position refresh interval --- .../web3/trade/perps/AllPositionsFragment.kt | 27 +++++++++++++------ .../home/web3/trade/perps/PerpetualContent.kt | 2 +- .../web3/trade/perps/PerpetualGuidePage.kt | 13 ++++----- .../ui/home/web3/trade/perps/PerpsActivity.kt | 2 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 6 ++--- .../web3/trade/perps/PositionDetailPage.kt | 4 +-- app/src/main/res/values-zh-rCN/strings.xml | 10 +++---- app/src/main/res/values/strings.xml | 10 +++---- 8 files changed, 43 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index 9f5f08ec60..3ab36f308c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -13,16 +13,19 @@ import androidx.paging.PagedList import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.databinding.FragmentAllClosedPositionsBinding +import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.openUrl import one.mixin.android.session.Session @@ -31,6 +34,7 @@ import one.mixin.android.ui.home.web3.trade.ClosedPositionAdapter import one.mixin.android.ui.home.web3.trade.TotalPositionValueAdapter import one.mixin.android.util.viewBinding import java.math.BigDecimal +import javax.inject.Inject @AndroidEntryPoint class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions) { @@ -54,6 +58,9 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions fun newClosedInstance() = newInstance(initialOpenTab = false) } + @Inject + lateinit var perpsMarketDao: PerpsMarketDao + private val binding by viewBinding(FragmentAllClosedPositionsBinding::bind) private val viewModel by viewModels() private val totalValueAdapter by lazy { TotalPositionValueAdapter() } @@ -63,15 +70,19 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions private val openPositionAdapter by lazy { OpenPositionAdapter(isQuoteColorReversed) { position -> - activity?.supportFragmentManager?.let { fm -> - fm.beginTransaction() - .add( - android.R.id.content, - PositionDetailFragment.Companion.newInstance(position), - PositionDetailFragment.Companion.TAG + lifecycleScope.launch { + val market = withContext(Dispatchers.IO) { + perpsMarketDao.getMarket(position.productId) + } + activity?.let { ctx -> + PerpsActivity.showDetail( + context = ctx, + marketId = position.productId, + marketSymbol = market?.symbol ?: "", + marketDisplaySymbol = market?.displaySymbol ?: "", + marketTokenSymbol = market?.tokenSymbol ?: "" ) - .addToBackStack(null) - .commit() + } } } } 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 7fceb87ebb..ea4146cb40 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 @@ -62,7 +62,7 @@ import one.mixin.android.vo.Fiats import java.math.BigDecimal import kotlin.math.abs -private const val POSITION_REFRESH_INTERVAL_MS = 10_000L +private const val POSITION_REFRESH_INTERVAL_MS = 3_000L private const val CLOSED_POSITION_PREVIEW_LIMIT = 10 @Composable diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 87c0b0cfb7..63e917c12a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -292,9 +292,10 @@ private fun ShortContent() { @Composable private fun LeverageContent() { var leverage by remember { mutableIntStateOf(10) } - val maxLossPercent = if (leverage > 0) 100f / leverage else 100f + val fixedScenarioPercent = 10f val basePnlAmount = leverage * 100 val basePnlPercent = leverage * 10 + val cappedLossAmount = basePnlAmount.coerceAtMost(1000) ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( @@ -320,7 +321,7 @@ private fun LeverageContent() { ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), - initialPercent = maxLossPercent, + initialPercent = fixedScenarioPercent, basePnlAmount = basePnlAmount, basePnlPercent = basePnlPercent, isProfit = true, @@ -329,11 +330,11 @@ private fun LeverageContent() { ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), - initialPercent = maxLossPercent, - basePnlAmount = basePnlAmount, - basePnlPercent = basePnlPercent, + initialPercent = fixedScenarioPercent, + basePnlAmount = cappedLossAmount, + basePnlPercent = basePnlPercent.coerceAtMost(100), isProfit = false, - maxPercent = maxLossPercent, + maxPercent = fixedScenarioPercent, ) ), leverageValue = leverage, 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 f23d64ea0a..1acbd31174 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 @@ -46,7 +46,7 @@ class PerpsActivity : BaseActivity() { private const val EXTRA_MARKET_TOKEN_SYMBOL = "extra_market_token_symbol" private const val EXTRA_MODE = "extra_mode" private const val EXTRA_IS_LONG = "extra_is_long" - private const val POSITION_REFRESH_INTERVAL_MS = 10_000L + private const val POSITION_REFRESH_INTERVAL_MS = 3_000L const val MODE_DETAIL = "detail" const val MODE_OPEN_POSITION = "open_position" 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 d819052e91..c30f494d86 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 @@ -705,7 +705,7 @@ private fun OpenPositionCard( fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) - /* Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(4.dp)) Icon( painter = painterResource(id = R.drawable.ic_tip), contentDescription = null, @@ -713,11 +713,11 @@ private fun OpenPositionCard( .size(12.dp) .clickable { val activity = context as? FragmentActivity ?: return@clickable - PerpetualGuideFragment.newInstance() + PerpetualGuideFragment.newInstance(PerpetualGuideFragment.TAB_POSITION) .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) }, tint = MixinAppTheme.colors.textAssist - )*/ + ) } Text( text = "${quantity.stripTrailingZeros().toPlainString()} ${position.tokenSymbol}", 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 1eae02a1d4..ec4b836bd9 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 @@ -159,7 +159,7 @@ fun PositionDetailPage( Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) - .background(sideColor.copy(alpha = 0.2f)) + .background(sideColor.copy(alpha = 0.1f)) .padding(horizontal = 8.dp, vertical = 2.5.dp) .align(Alignment.CenterHorizontally) ) { @@ -490,7 +490,7 @@ fun PositionDetailPage( Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) - .background(sideColor.copy(alpha = 0.2f)) + .background(sideColor.copy(alpha = 0.1f)) .padding(horizontal = 8.dp, vertical = 2.5.dp) .align(Alignment.CenterHorizontally) ) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 90ac28e267..ba1ee1cd9f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -471,8 +471,8 @@ 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 错误 10650: 订单价值太小,请调整后重试。 - 错误 10651: 当前市场已有活跃仓位。 - 等待其他订单完成 + 错误 10651:当前市场已开仓。 + 当前市场已开仓。 错误 20114:验证码已过期 错误 20113:验证码错误 你已经尝试了超过 5 次,请等待 24 小时后再次尝试。 @@ -2239,7 +2239,7 @@ 做空意味着您预期价格会下跌。如果价格下跌,您就会获利。如果价格上涨,您就会亏损。您的盈亏会被杠杆倍数放大。 杠杆允许您用更少的资金控制更大的仓位。例如,使用 10 倍杠杆,1%% 的价格变动会导致 10%% 的盈亏。杠杆越高,风险越大。 您可以随时平仓以实现盈亏。平仓价格基于当前市场价格。请务必监控您的仓位以避免爆仓。 - 仓位 + 订单价值 具体说明 Mixin 永续合约是一种以数字资产结算的衍生品交易方式,支持做多和做空,无到期日。通过杠杆机制,交易者可以放大仓位,把握价格上涨或下跌带来的交易机会。 产品特点 @@ -2277,7 +2277,7 @@ 盈亏影响 杠杆会同时放大收益和亏损。杠杆倍数越高,盈亏随价格波动的变化越大。 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 - 当前合约持仓的实际交易价值,由「保证金 × 杠杆」决定。 + 当前合约订单价值,由「保证金 × 杠杆」决定。 用途 支撑当前仓位,抵扣浮动亏损。 支撑当前仓位 @@ -2319,7 +2319,7 @@ 平仓做多 平仓做空 预估强平价格 - 持仓总价值 + 订单总价值 %1$s %2$s %1$d倍 持仓中 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19b742f31a..1ead1c2d69 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -484,8 +484,8 @@ 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 10650: Order value is too small. - ERROR 10651: Market already has an active position. - Wait for other orders to complete. + ERROR 10651: The current market already has an open position. + The current market already has an open position. ERROR 20114: Expired phone verification code ERROR 20113: Invalid phone verification code You have tried more than 5 times, please wait at least 24 hours to try again. @@ -2298,7 +2298,7 @@ Going short means you expect the price to decrease. If the price goes down, you profit. If it goes up, you lose. Your profit/loss is multiplied by your leverage. Leverage allows you to control a larger position with less capital. For example, with 10x leverage, a 1%% price move results in a 10%% profit or loss. Higher leverage means higher risk. You can close your position at any time to realize your profit or loss. The closing price is based on the current market price. Make sure to monitor your positions to avoid liquidation. - Open Position + Order Value Instructions Mixin perpetual contracts are derivative trading instruments settled in digital assets, supporting long and short positions with no expiration date. Through leverage, traders can amplify positions to capture trading opportunities from price movements. Product Features @@ -2336,7 +2336,7 @@ P&L Impact Leverage amplifies both gains and losses. Higher leverage means greater P&L fluctuation with price movements. Please choose leverage reasonably. With high leverage, even small price movements can lead to significant losses. - The actual trading value of current contract position, determined by "Margin × Leverage". + The order value of current contract position, determined by "Margin × Leverage". Usage Support current position and offset floating losses. Support current position. @@ -2384,7 +2384,7 @@ Close Long Close Short Estimated Liquidation Price - Total Position Value + Total Order Value %1$s %2$s %1$dx $%1$f From f3f56b4fd7d7033ba2fd2f9af8be7597afb14807 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 9 Mar 2026 20:04:48 +0800 Subject: [PATCH 062/105] Delete .codex/environments/environment.toml --- .codex/environments/environment.toml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .codex/environments/environment.toml diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml deleted file mode 100644 index cd7aeb2fab..0000000000 --- a/.codex/environments/environment.toml +++ /dev/null @@ -1,11 +0,0 @@ -# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY -version = 1 -name = "android" - -[setup] -script = "" - -[[actions]] -name = "运行" -icon = "run" -command = "./gradlew installGooglePlayDebug" From 78b49eb9b044e37d1a14bc20cf94501ff89bef17 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 10 Mar 2026 09:49:01 +0800 Subject: [PATCH 063/105] Fix guide and trade again --- .../perps/PerpsPositionHistoryItem.kt | 6 +- .../db/perps/PerpsPositionHistoryDao.kt | 8 +- .../trade/perps/AllPerpsMarketsFragment.kt | 2 +- .../web3/trade/perps/AllPositionsFragment.kt | 4 +- .../web3/trade/perps/OpenPositionAdapter.kt | 2 +- .../home/web3/trade/perps/OpenPositionItem.kt | 1 - .../home/web3/trade/perps/PerpetualContent.kt | 7 +- .../web3/trade/perps/PerpetualGuidePage.kt | 95 +++++++++++-------- .../web3/trade/perps/PerpetualViewModel.kt | 10 +- .../ui/home/web3/trade/perps/PerpsActivity.kt | 14 +-- .../PerpsCloseBottomSheetDialogFragment.kt | 2 +- .../PerpsConfirmBottomSheetDialogFragment.kt | 2 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 2 +- .../home/web3/trade/perps/PerpsMarketItem.kt | 2 - .../trade/perps/PositionDetailFragment.kt | 12 +-- .../web3/trade/perps/PositionDetailPage.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 18 files changed, 95 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt index 1894a8a979..cf7e88af3e 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt @@ -16,9 +16,9 @@ data class PerpsPositionHistoryItem( @SerializedName("product_id") @ColumnInfo(name = "product_id") val productId: String, - @SerializedName("market_symbol") - @ColumnInfo(name = "market_symbol") - val marketSymbol: String? = null, + @SerializedName("symbol") + @ColumnInfo(name = "symbol") + val symbol: String? = null, @SerializedName("side") @ColumnInfo(name = "side") val side: String, diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt index 1b4bfd50dc..fb79c6c9f1 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -19,7 +19,7 @@ interface PerpsPositionHistoryDao : BaseDao { suspend fun insertAll(histories: List) @Query(""" - SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol, m.symbol FROM position_history h LEFT JOIN markets m ON m.market_id = h.product_id WHERE h.wallet_id = :walletId @@ -30,7 +30,7 @@ interface PerpsPositionHistoryDao : BaseDao { @Query( """ - SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol, m.symbol FROM position_history h LEFT JOIN markets m ON m.market_id = h.product_id WHERE h.wallet_id = :walletId @@ -41,7 +41,7 @@ interface PerpsPositionHistoryDao : BaseDao { fun observeHistories(walletId: String, limit: Int): Flow> @Query(""" - SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol, m.symbol FROM position_history h LEFT JOIN markets m ON m.market_id = h.product_id WHERE h.wallet_id = :walletId @@ -50,7 +50,7 @@ interface PerpsPositionHistoryDao : BaseDao { fun getHistoriesPaged(walletId: String): DataSource.Factory @Query(""" - SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol, m.symbol FROM position_history h LEFT JOIN markets m ON m.market_id = h.product_id WHERE h.history_id = :historyId diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt index 5768819b07..a5e7a55803 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt @@ -25,8 +25,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index 3ab36f308c..2d9f40f802 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -4,8 +4,8 @@ import android.os.Bundle import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.viewModels -import androidx.lifecycle.LiveData import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -22,8 +22,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.Constants import one.mixin.android.R -import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.databinding.FragmentAllClosedPositionsBinding import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.extension.defaultSharedPreferences diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt index e130ac8b7e..0f9b358c9f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt @@ -1,8 +1,8 @@ package one.mixin.android.ui.home.web3.trade.perps import android.util.TypedValue -import android.view.View import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.annotation.AttrRes import androidx.core.view.isVisible diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt index 0d7e773ebc..83d192d229 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt @@ -1,7 +1,6 @@ package one.mixin.android.ui.home.web3.trade.perps import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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 ea4146cb40..42cbcb6d8b 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 @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -46,15 +45,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import one.mixin.android.R import one.mixin.android.Constants +import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket -import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences -import one.mixin.android.extension.putStringSet import one.mixin.android.extension.priceFormat +import one.mixin.android.extension.putStringSet import one.mixin.android.session.Session import one.mixin.android.ui.home.web3.trade.ClosedPositionItem import one.mixin.android.ui.wallet.alert.components.cardBackground diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 63e917c12a..56fddda9e6 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -4,8 +4,6 @@ import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,6 +16,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -30,11 +30,11 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.draw.clip +import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -44,9 +44,6 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch -import java.math.BigDecimal -import java.math.RoundingMode -import kotlin.math.roundToInt import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.compose.theme.MixinAppTheme @@ -54,6 +51,9 @@ import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.ui.home.web3.components.OutlinedTab import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.widget.components.DotText +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.roundToInt data class ScenarioData( val scenario: String, @@ -72,6 +72,12 @@ data class GuideRowData( @DrawableRes val iconRes: Int? = null, ) +data class AdjusterConfig( + val min: Int, + val max: Int, + val step: Int, +) + @Composable fun PerpetualGuidePage( initialTab: Int = PerpetualGuideFragment.TAB_OVERVIEW, @@ -292,10 +298,10 @@ private fun ShortContent() { @Composable private fun LeverageContent() { var leverage by remember { mutableIntStateOf(10) } - val fixedScenarioPercent = 10f - val basePnlAmount = leverage * 100 - val basePnlPercent = leverage * 10 - val cappedLossAmount = basePnlAmount.coerceAtMost(1000) + val fixedProfitPercent = 10f + val liquidationPercent = if (leverage > 0) 100f / leverage else 100f + val profitPnlAmount = leverage * 100 + val profitPnlPercent = leverage * 10 ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), rows = listOf( @@ -321,20 +327,20 @@ private fun LeverageContent() { ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), - initialPercent = fixedScenarioPercent, - basePnlAmount = basePnlAmount, - basePnlPercent = basePnlPercent, + initialPercent = fixedProfitPercent, + basePnlAmount = profitPnlAmount, + basePnlPercent = profitPnlPercent, isProfit = true, maxPercent = null ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), - initialPercent = fixedScenarioPercent, - basePnlAmount = cappedLossAmount, - basePnlPercent = basePnlPercent.coerceAtMost(100), + initialPercent = liquidationPercent, + basePnlAmount = 1000, + basePnlPercent = 100, isProfit = false, - maxPercent = fixedScenarioPercent, + maxPercent = liquidationPercent, ) ), leverageValue = leverage, @@ -356,6 +362,7 @@ private fun PositionContent() { var leverage by remember { mutableIntStateOf(10) } var investment by remember { mutableIntStateOf(1000) } val maxLossPercent = if (leverage > 0) 100f / leverage else 100f + val fixedProfitPercent = 10f val solToken by remember { viewModel.observeTokenByChainAndSymbol( chainId = Constants.ChainId.Solana, @@ -369,8 +376,10 @@ private fun PositionContent() { orderValueUsdt = orderValueUsdt, localSolPrice = localSolPrice ) - val basePnlAmount = (orderValueUsdt * maxLossPercent / 100).toInt() - val basePnlPercent = (leverage * maxLossPercent).toInt() + val profitPnlAmount = (orderValueUsdt * fixedProfitPercent / 100).roundToInt() + val profitPnlPercent = (leverage * fixedProfitPercent).roundToInt() + val lossPnlAmount = investment + val lossPnlPercent = 100 ExampleWithScenariosCard( title = stringResource(R.string.Perpetual_Example), @@ -401,9 +410,9 @@ private fun PositionContent() { ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), change = stringResource(R.string.Perpetual_Price_Up_Amplitude), - initialPercent = maxLossPercent, - basePnlAmount = basePnlAmount, - basePnlPercent = basePnlPercent, + initialPercent = fixedProfitPercent, + basePnlAmount = profitPnlAmount, + basePnlPercent = profitPnlPercent, isProfit = true, maxPercent = null ), @@ -411,18 +420,19 @@ private fun PositionContent() { scenario = stringResource(R.string.Perpetual_Price_Down), change = stringResource(R.string.Perpetual_Price_Down_Amplitude), initialPercent = maxLossPercent, - basePnlAmount = basePnlAmount, - basePnlPercent = basePnlPercent, + basePnlAmount = lossPnlAmount, + basePnlPercent = lossPnlPercent, isProfit = false, maxPercent = maxLossPercent, ) ), leverageValue = leverage, - onLeverageChange = { leverage = it.coerceIn(1, 100) }, + onLeverageChange = { leverage = it.coerceIn(1, 200) }, + leverageConfig = AdjusterConfig(min = 1, max = 200, step = 1), investmentValue = investment, - onInvestmentChange = { investment = it.coerceIn(10, 1000) }, + onInvestmentChange = { investment = it.coerceIn(100, 100000) }, + investmentConfig = AdjusterConfig(min = 100, max = 100000, step = 100), isScenarioChangeAdjustable = false, - maxLeverage = 100, ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithInfoAndRiskCard( @@ -606,10 +616,11 @@ private fun ExampleWithScenariosCard( scenarios: List, leverageValue: Int? = null, onLeverageChange: ((Int) -> Unit)? = null, + leverageConfig: AdjusterConfig = AdjusterConfig(min = 1, max = 200, step = 1), investmentValue: Int? = null, onInvestmentChange: ((Int) -> Unit)? = null, + investmentConfig: AdjusterConfig = AdjusterConfig(min = 10, max = 1000, step = 10), isScenarioChangeAdjustable: Boolean = true, - maxLeverage: Int = 200, ) { val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences @@ -674,18 +685,26 @@ private fun ExampleWithScenariosCard( } else if (label == leverageLabel && leverageValue != null && onLeverageChange != null) { GuideNumberAdjuster( valueText = "${leverageValue}x", - canDecrease = leverageValue > 1, - canIncrease = leverageValue < maxLeverage, - onDecrease = { onLeverageChange((leverageValue - 1).coerceAtLeast(1)) }, - onIncrease = { onLeverageChange((leverageValue + 1).coerceAtMost(maxLeverage)) }, + canDecrease = leverageValue > leverageConfig.min, + canIncrease = leverageValue < leverageConfig.max, + onDecrease = { + onLeverageChange((leverageValue - leverageConfig.step).coerceAtLeast(leverageConfig.min)) + }, + onIncrease = { + onLeverageChange((leverageValue + leverageConfig.step).coerceAtMost(leverageConfig.max)) + }, ) } else if (label == investmentLabel && investmentValue != null && onInvestmentChange != null) { GuideNumberAdjuster( valueText = "${formatGuideInt(investmentValue)} USDT", - canDecrease = investmentValue > 10, - canIncrease = investmentValue < 1000, - onDecrease = { onInvestmentChange((investmentValue - 10).coerceAtLeast(10)) }, - onIncrease = { onInvestmentChange((investmentValue + 10).coerceAtMost(1000)) }, + canDecrease = investmentValue > investmentConfig.min, + canIncrease = investmentValue < investmentConfig.max, + onDecrease = { + onInvestmentChange((investmentValue - investmentConfig.step).coerceAtLeast(investmentConfig.min)) + }, + onIncrease = { + onInvestmentChange((investmentValue + investmentConfig.step).coerceAtMost(investmentConfig.max)) + }, ) } else { Row(verticalAlignment = Alignment.CenterVertically) { 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 0201bd8366..323591f1e2 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 @@ -1,8 +1,8 @@ package one.mixin.android.ui.home.web3.trade.perps +import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.lifecycle.LiveData import androidx.paging.LivePagedListBuilder import androidx.paging.PagedList import dagger.hilt.android.lifecycle.HiltViewModel @@ -12,17 +12,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import one.mixin.android.Constants import one.mixin.android.api.request.perps.CloseOrderRequest -import one.mixin.android.api.response.perps.CandleView -import one.mixin.android.api.response.perps.PerpsMarket -import one.mixin.android.api.service.RouteService import one.mixin.android.api.request.perps.OpenOrderRequest import one.mixin.android.api.request.perps.OpenOrderResponse +import one.mixin.android.api.response.perps.CandleView +import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.api.response.perps.PerpsPosition import one.mixin.android.api.response.perps.PerpsPositionHistoryItem import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.service.RouteService import one.mixin.android.db.TokenDao -import one.mixin.android.db.perps.PerpsPositionDao import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.db.perps.PerpsPositionDao import one.mixin.android.db.perps.PerpsPositionHistoryDao import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshPerpsPositionsJob 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 1acbd31174..5ac14cea58 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 @@ -11,21 +11,21 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import one.mixin.android.R import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.db.perps.PerpsMarketDao import one.mixin.android.extension.toast import one.mixin.android.job.MixinJobManager import one.mixin.android.job.RefreshPerpsPositionsJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import one.mixin.android.session.Session -import one.mixin.android.R import one.mixin.android.ui.common.BaseActivity -import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.ui.wallet.TokenListBottomSheetDialogFragment +import one.mixin.android.ui.wallet.WalletActivity import one.mixin.android.vo.safe.TokenItem import javax.inject.Inject 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 7700c11451..84cfcca7a0 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 @@ -70,8 +70,8 @@ import one.mixin.android.ui.tip.wc.compose.ItemWalletContent import one.mixin.android.ui.wallet.ItemUserContent import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager -import one.mixin.android.vo.User import one.mixin.android.vo.Fiats +import one.mixin.android.vo.User import one.mixin.android.vo.safe.TokenItem import timber.log.Timber import java.math.BigDecimal 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 7a5a7a9de6..a3c550ab83 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 @@ -79,8 +79,8 @@ import one.mixin.android.ui.tip.wc.compose.ItemWalletContent import one.mixin.android.ui.wallet.ItemUserContent import one.mixin.android.ui.wallet.components.WalletLabel import one.mixin.android.util.SystemUIManager -import one.mixin.android.vo.User import one.mixin.android.vo.Fiats +import one.mixin.android.vo.User import one.mixin.android.vo.toUser import timber.log.Timber import java.math.BigDecimal 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 c30f494d86..501b02abe5 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 @@ -51,8 +51,8 @@ import kotlinx.coroutines.flow.flowOf import one.mixin.android.Constants import one.mixin.android.R import one.mixin.android.api.response.perps.PerpsMarket -import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +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 diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt index 6d8e8d8642..51b64d2ab1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt @@ -18,7 +18,6 @@ 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.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -28,7 +27,6 @@ import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.numberFormatCompact import one.mixin.android.extension.priceFormat -import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats import java.math.BigDecimal diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt index 2cf30d9621..8195748fc1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -4,9 +4,9 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner @@ -16,14 +16,14 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import one.mixin.android.Constants +import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.api.response.perps.PerpsPositionItem +import one.mixin.android.api.response.perps.toPosition import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getParcelableCompat import one.mixin.android.extension.isNightMode import one.mixin.android.extension.openUrl -import one.mixin.android.api.response.perps.PerpsPositionItem -import one.mixin.android.api.response.perps.PerpsPositionHistoryItem -import one.mixin.android.api.response.perps.toPosition import one.mixin.android.ui.common.BaseFragment @AndroidEntryPoint @@ -149,8 +149,8 @@ class PositionDetailFragment : BaseFragment() { PerpsActivity.showDetail( context = requireContext(), marketId = positionHistory.productId, - marketSymbol = positionHistory.marketSymbol ?: positionHistory.tokenSymbol.orEmpty(), - marketDisplaySymbol = positionHistory.displaySymbol ?: positionHistory.tokenSymbol.orEmpty(), + marketSymbol = positionHistory.symbol.orEmpty(), + marketDisplaySymbol = positionHistory.displaySymbol.orEmpty(), marketTokenSymbol = positionHistory.tokenSymbol.orEmpty() ) } 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 ec4b836bd9..bc1bcaaccc 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 @@ -35,8 +35,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import one.mixin.android.R -import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.api.response.perps.PerpsPositionHistoryItem +import one.mixin.android.api.response.perps.PerpsPositionItem import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.priceFormat diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ba1ee1cd9f..914500be30 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2245,7 +2245,7 @@ 产品特点 无到期日,可长期持仓 支持做多 / 做空,双向交易 - 最高支持 100 倍杠杆 + 最高支持 200 倍杠杆 支持逐仓模式,灵活控制风险 延迟爆仓,防插针 风险提示 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ead1c2d69..c96ff850e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2304,7 +2304,7 @@ Product Features No expiration date, can hold positions long-term Support long/short, bidirectional trading - Up to 100x leverage supported + Up to 200x leverage supported Support isolated margin mode for flexible risk control Delayed liquidation to prevent flash crashes Risk Warning From fc3c8c40ef0ee55d48136388eb949c4c6bcf2aca Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 10 Mar 2026 15:42:50 +0800 Subject: [PATCH 064/105] Update strings --- .../web3/trade/perps/AllPositionsFragment.kt | 30 +++++++++++--- .../web3/trade/perps/PerpetualGuidePage.kt | 39 +++++++++++++------ .../web3/trade/perps/PerpsMarketDetailPage.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 19 +++++---- app/src/main/res/values/strings.xml | 17 +++++--- gradle/gradle-daemon-jvm.properties | 12 ++++++ 6 files changed, 87 insertions(+), 32 deletions(-) create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index 2d9f40f802..fdb6a49c97 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -111,6 +111,11 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions private var openPositionsLiveData: LiveData>? = null private var closedPositionsLiveData: LiveData>? = null private var totalValueJob: Job? = null + + private var lastOpenTotalValue: Double = 0.0 + private var lastOpenTotalPnl: Double = 0.0 + private var lastClosedTotalPnl: Double = 0.0 + private var lastClosedTotalEntryValue: Double = 0.0 private val openPositionsObserver = Observer> { pagedList -> binding.progressBar.isVisible = false @@ -172,6 +177,11 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions openPositionsLiveData?.removeObservers(viewLifecycleOwner) closedPositionsLiveData?.removeObservers(viewLifecycleOwner) totalValueJob?.cancel() + + lastOpenTotalValue = 0.0 + lastOpenTotalPnl = 0.0 + lastClosedTotalPnl = 0.0 + lastClosedTotalEntryValue = 0.0 if (currentTab == PositionTab.OPEN) { binding.titleView.setSubTitle(getString(R.string.Open_Positions_Simple), "") @@ -228,9 +238,13 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions ) { totalPositionValue, totalPnl -> totalPositionValue to totalPnl }.collect { (totalPositionValue, totalPnl) -> - val percent = calculatePercent(totalPnl, totalPositionValue) - totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPositionValue)) - totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + if (lastOpenTotalValue != totalPositionValue || lastOpenTotalPnl != totalPnl) { + lastOpenTotalValue = totalPositionValue + lastOpenTotalPnl = totalPnl + val percent = calculatePercent(totalPnl, totalPositionValue) + totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPositionValue)) + totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + } } } } @@ -246,9 +260,13 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions ) { totalPnl, totalEntryValue -> totalPnl to totalEntryValue }.collect { (totalPnl, totalEntryValue) -> - val percent = calculatePercent(totalPnl, totalEntryValue) - totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) - totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + if (lastClosedTotalPnl != totalPnl || lastClosedTotalEntryValue != totalEntryValue) { + lastClosedTotalPnl = totalPnl + lastClosedTotalEntryValue = totalEntryValue + val percent = calculatePercent(totalPnl, totalEntryValue) + totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) + totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + } } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 56fddda9e6..9935ebbbe3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -130,7 +130,9 @@ fun PerpetualGuidePage( .padding(horizontal = 16.dp) ) { Spacer(modifier = Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + Row(modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState())) { tabs.forEachIndexed { index, tab -> OutlinedTab( text = tab, @@ -351,8 +353,11 @@ private fun LeverageContent() { DescriptionWithInfoAndRiskCard( description = stringResource(R.string.Perpetual_Leverage_Desc), infoTitle = stringResource(R.string.Perpetual_PnL_Impact), - infoContents = listOf(stringResource(R.string.Perpetual_Leverage_Impact)), - riskContent = stringResource(R.string.Perpetual_Leverage_Risk) + infoContents = listOf( + stringResource(R.string.Perpetual_Leverage_Impact_1), + stringResource(R.string.Perpetual_Leverage_Impact_2) + ), + riskContents = listOf(stringResource(R.string.Perpetual_Leverage_Risk)) ) } @@ -439,10 +444,10 @@ private fun PositionContent() { description = stringResource(R.string.Perpetual_Position_Desc), infoTitle = stringResource(R.string.Perpetual_Position_Usage), infoContents = listOf( - stringResource(R.string.Perpetual_Position_Usage_Support_Current_Position), - stringResource(R.string.Perpetual_Position_Usage_Offset_Floating_Losses), + stringResource(R.string.Perpetual_Position_Usage_Desc_1), + stringResource(R.string.Perpetual_Position_Usage_Desc_2) ), - riskContent = stringResource(R.string.Perpetual_Position_Risk) + riskContents = listOf(stringResource(R.string.Perpetual_Position_Risk_1) , stringResource(R.string.Perpetual_Position_Risk_2)) ) } @@ -603,7 +608,15 @@ private fun GuideSection(title: String, content: String) { ) Spacer(modifier = Modifier.height(8.dp)) DotText( - text = stringResource(R.string.Perpetual_Risk_Warning_Content), + text = stringResource(R.string.Perpetual_Risk_Warning_Content_1), + color = MixinAppTheme.colors.textPrimary + ) + DotText( + text = stringResource(R.string.Perpetual_Risk_Warning_Content_2), + color = MixinAppTheme.colors.textPrimary + ) + DotText( + text = stringResource(R.string.Perpetual_Risk_Warning_Content_3), color = MixinAppTheme.colors.textPrimary ) } @@ -996,7 +1009,7 @@ private fun DescriptionWithInfoAndRiskCard( description: String, infoTitle: String, infoContents: List, - riskContent: String, + riskContents: List, ) { Column( modifier = Modifier @@ -1053,10 +1066,12 @@ private fun DescriptionWithInfoAndRiskCard( color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(6.dp)) - DotText( - text = riskContent, - color = MixinAppTheme.colors.textPrimary - ) + riskContents.forEach { riskContent -> + DotText( + text = riskContent, + color = MixinAppTheme.colors.textPrimary + ) + } } } } 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 501b02abe5..219a8f616e 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 @@ -729,7 +729,7 @@ private fun OpenPositionCard( Column(horizontalAlignment = Alignment.End) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(R.string.Perpetual_Guide_Position), + text = stringResource(R.string.Amount), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 914500be30..9b080b6782 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2249,7 +2249,9 @@ 支持逐仓模式,灵活控制风险 延迟爆仓,防插针 风险提示 - 杠杆交易可能放大收益,同时也会放大亏损。当保证金不足时,仓位可能被强制平仓。请合理控制杠杆倍数与仓位规模,谨慎交易。 + 杠杆交易可能放大收益,同时也会放大亏损。 + 当保证金不足时,仓位可能被强制平仓。 + 请合理控制杠杆倍数与仓位规模,谨慎交易。 举例说明 交易对 开仓方向 @@ -2275,21 +2277,24 @@ 盈亏规则 杠杆倍数用于放大交易规模,以较少的保证金控制更大的合约仓位。 盈亏影响 - 杠杆会同时放大收益和亏损。杠杆倍数越高,盈亏随价格波动的变化越大。 + 杠杆会同时放大收益和亏损。 + 杠杆倍数越高,盈亏随价格波动的变化越大。 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 - 当前合约订单价值,由「保证金 × 杠杆」决定。 + 当前合约的订单价值由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 用途 - 支撑当前仓位,抵扣浮动亏损。 + 决定本次交易的市场敞口规模。 + 影响盈亏变化的放大倍数。 支撑当前仓位 抵扣浮动亏损 - 当投入资金不足以支撑当前仓位时,仓位将被系统强制平仓。价格剧烈波动可能会快速消耗投入资金。 + 当亏损接近已投入资金时,可能被系统强制平仓。 + 价格剧烈波动可能会快速消耗投入资金。 永续合约 开仓 杠杆 选择代币 选择杠杆 订单价值 - 强平价格 + 清算价格 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 做空 @@ -2318,7 +2323,7 @@ 开仓做空 平仓做多 平仓做空 - 预估强平价格 + 预估清算价格 订单总价值 %1$s %2$s %1$d倍 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c96ff850e7..f688226dd8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2308,7 +2308,9 @@ Support isolated margin mode for flexible risk control Delayed liquidation to prevent flash crashes Risk Warning - Leverage trading can amplify gains but also losses. When margin is insufficient, positions may be forcibly liquidated. Please control leverage and position size reasonably and trade cautiously. + Leverage trading can amplify gains but also losses. + When margin is insufficient, positions may be forcibly liquidated. + Please control leverage and position size reasonably and trade cautiously. Example Trading Pair Direction @@ -2334,14 +2336,17 @@ P&L Rules Leverage multiplier is used to amplify trading size, controlling larger contract positions with less margin. P&L Impact - Leverage amplifies both gains and losses. Higher leverage means greater P&L fluctuation with price movements. - Please choose leverage reasonably. With high leverage, even small price movements can lead to significant losses. - The order value of current contract position, determined by "Margin × Leverage". + Leverage amplifies both gains and losses. + Higher leverage means greater P&L fluctuation with price movements. + Please choose leverage reasonably, with high leverage, even small price movements can lead to significant losses. + The order value of current contract is calculated by "Margin × Leverage", representing the asset size controlled in this transaction. Usage - Support current position and offset floating losses. + Determines the market exposure size of this transaction. + Affects the amplification factor of profit and loss changes. Support current position. Offset floating losses. - When investment is insufficient to support current position, the position will be forcibly liquidated by the system. Severe price volatility may rapidly consume investment. + When losses approach the invested capital, a forced liquidation by the system may occur. + Severe price volatility may rapidly consume investment. Perpetual Open Position Leverage diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000000..6c1139ec06 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 From dfda175096c1a77ed84ac8e71364f8276abed9ed Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 10 Mar 2026 20:15:56 +0800 Subject: [PATCH 065/105] Update database schema --- .../one.mixin.android.db.PerpsDatabase/1.json | 122 ++++++++++++------ .../api/request/perps/OpenOrderRequest.kt | 8 +- .../android/api/response/perps/CandleView.kt | 4 +- .../android/api/response/perps/PerpsExt.kt | 5 +- .../android/api/response/perps/PerpsMarket.kt | 90 +++++++++---- .../api/response/perps/PerpsPosition.kt | 12 +- .../response/perps/PerpsPositionHistory.kt | 6 +- .../perps/PerpsPositionHistoryItem.kt | 9 +- .../api/response/perps/PerpsPositionItem.kt | 14 +- .../mixin/android/api/service/RouteService.kt | 6 +- .../mixin/android/db/perps/PerpsMarketDao.kt | 10 +- .../android/db/perps/PerpsPositionDao.kt | 10 +- .../db/perps/PerpsPositionHistoryDao.kt | 16 +-- .../link/LinkBottomSheetDialogFragment.kt | 2 +- .../android/ui/home/web3/trade/CandleChart.kt | 6 +- .../ui/home/web3/trade/TradeFragment.kt | 2 +- .../trade/perps/AllPerpsMarketsFragment.kt | 2 +- .../web3/trade/perps/AllPositionsFragment.kt | 57 ++++---- .../home/web3/trade/perps/OpenPositionPage.kt | 7 +- .../home/web3/trade/perps/PerpetualContent.kt | 4 +- .../web3/trade/perps/PerpetualViewModel.kt | 19 +-- .../PerpsCloseBottomSheetDialogFragment.kt | 6 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 66 +++++----- ...erpsMarketListBottomSheetDialogFragment.kt | 5 +- .../trade/perps/PositionDetailFragment.kt | 4 +- .../layout/fragment_all_closed_positions.xml | 46 +------ app/src/main/res/values-zh-rCN/strings.xml | 6 +- app/src/main/res/values/strings.xml | 8 +- 28 files changed, 297 insertions(+), 255 deletions(-) diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index 7a313a34a4..fa8a9e0de8 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "aa2a40bfcda9610faa30a7e0d18637fa", + "identityHash": "f17028ee96c6829315578a046a8e4856", "entities": [ { "tableName": "positions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `settle_asset_id` TEXT, `bot_id` TEXT, `margin` TEXT, `state` TEXT, `mark_price` TEXT, `unrealized_pnl` TEXT, `roe` TEXT, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, PRIMARY KEY(`position_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `settle_asset_id` TEXT, `bot_id` TEXT, `margin` TEXT, `open_pay_amount` TEXT, `open_pay_asset_id` TEXT, `state` TEXT, `mark_price` TEXT, `unrealized_pnl` TEXT, `roe` TEXT, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, PRIMARY KEY(`position_id`))", "fields": [ { "fieldPath": "positionId", @@ -15,8 +15,8 @@ "notNull": true }, { - "fieldPath": "productId", - "columnName": "product_id", + "fieldPath": "marketId", + "columnName": "market_id", "affinity": "TEXT", "notNull": true }, @@ -59,6 +59,16 @@ "columnName": "margin", "affinity": "TEXT" }, + { + "fieldPath": "openPayAmount", + "columnName": "open_pay_amount", + "affinity": "TEXT" + }, + { + "fieldPath": "openPayAssetId", + "columnName": "open_pay_asset_id", + "affinity": "TEXT" + }, { "fieldPath": "state", "columnName": "state", @@ -104,7 +114,7 @@ }, { "tableName": "position_history", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`history_id` TEXT NOT NULL, `position_id` TEXT NOT NULL, `product_id` TEXT NOT NULL, `market_symbol` TEXT, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `close_price` TEXT NOT NULL, `realized_pnl` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `margin_method` TEXT, `open_at` TEXT NOT NULL, `closed_at` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, PRIMARY KEY(`history_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`history_id` TEXT NOT NULL, `position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `market_symbol` TEXT, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `close_price` TEXT NOT NULL, `realized_pnl` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `margin_method` TEXT, `open_at` TEXT NOT NULL, `closed_at` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, PRIMARY KEY(`history_id`))", "fields": [ { "fieldPath": "historyId", @@ -119,8 +129,8 @@ "notNull": true }, { - "fieldPath": "productId", - "columnName": "product_id", + "fieldPath": "marketId", + "columnName": "market_id", "affinity": "TEXT", "notNull": true }, @@ -198,7 +208,7 @@ }, { "tableName": "markets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `market` TEXT NOT NULL, `symbol` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `maker_fee` TEXT NOT NULL, `taker_fee` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `quantity_increment` TEXT NOT NULL, `price_increment` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `change` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `max_order_value` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `amount` TEXT NOT NULL, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `trade_count` INTEGER NOT NULL, `first_trade_id` INTEGER NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", "fields": [ { "fieldPath": "marketId", @@ -206,18 +216,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "market", - "columnName": "market", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "symbol", - "columnName": "symbol", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "displaySymbol", "columnName": "display_symbol", @@ -230,6 +228,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "quoteSymbol", + "columnName": "quote_symbol", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "markPrice", "columnName": "mark_price", @@ -237,20 +241,20 @@ "notNull": true }, { - "fieldPath": "fundingRate", - "columnName": "funding_rate", - "affinity": "TEXT", + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", "notNull": true }, { - "fieldPath": "makerFee", - "columnName": "maker_fee", + "fieldPath": "iconUrl", + "columnName": "icon_url", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "takerFee", - "columnName": "taker_fee", + "fieldPath": "fundingRate", + "columnName": "funding_rate", "affinity": "TEXT", "notNull": true }, @@ -273,14 +277,8 @@ "notNull": true }, { - "fieldPath": "quantityIncrement", - "columnName": "quantity_increment", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "priceIncrement", - "columnName": "price_increment", + "fieldPath": "maxOrderValue", + "columnName": "max_order_value", "affinity": "TEXT", "notNull": true }, @@ -297,14 +295,26 @@ "notNull": true }, { - "fieldPath": "leverage", - "columnName": "leverage", - "affinity": "INTEGER", + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT", "notNull": true }, { - "fieldPath": "iconUrl", - "columnName": "icon_url", + "fieldPath": "high", + "columnName": "high", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "low", + "columnName": "low", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "open", + "columnName": "open", "affinity": "TEXT", "notNull": true }, @@ -314,6 +324,36 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "bidPrice", + "columnName": "bid_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "askPrice", + "columnName": "ask_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeCount", + "columnName": "trade_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstTradeId", + "columnName": "first_trade_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "updatedAt", "columnName": "updated_at", @@ -331,7 +371,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aa2a40bfcda9610faa30a7e0d18637fa')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f17028ee96c6829315578a046a8e4856')" ] } } \ No newline at end of file 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 969f578320..ecfb99efb4 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 @@ -5,8 +5,8 @@ import com.google.gson.annotations.SerializedName data class OpenOrderRequest( @SerializedName("asset_id") val assetId: String, - @SerializedName("product_id") - val productId: String, + @SerializedName("market_id") + val marketId: String, @SerializedName("side") val side: String, @SerializedName("amount") @@ -22,8 +22,8 @@ data class OpenOrderRequest( data class OpenOrderResponse( @SerializedName("order_id") val orderId: String, - @SerializedName("pay_url") - val payUrl: String?, + @SerializedName("payment_url") + val paymentUrl: String?, @SerializedName("pay_amount") val payAmount: String?, @SerializedName("deposit_destination") diff --git a/app/src/main/java/one/mixin/android/api/response/perps/CandleView.kt b/app/src/main/java/one/mixin/android/api/response/perps/CandleView.kt index c9fb536a79..f07fc9b992 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/CandleView.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/CandleView.kt @@ -5,8 +5,8 @@ import com.google.gson.annotations.SerializedName data class CandleView( @SerializedName("market") val market: String, - @SerializedName("product") - val product: String, + @SerializedName(value = "market_id") + val marketId: String, @SerializedName("time_frame") val timeFrame: String, @SerializedName("updated_at") diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt index 49fd01b2d7..3ec049dfaf 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt @@ -3,7 +3,7 @@ package one.mixin.android.api.response.perps fun PerpsPositionItem.toPosition(): PerpsPosition { return PerpsPosition( positionId = positionId, - productId = productId, + marketId = marketId, side = side, quantity = quantity, entryPrice = entryPrice, @@ -11,6 +11,8 @@ fun PerpsPositionItem.toPosition(): PerpsPosition { settleAssetId = settleAssetId, botId = botId, margin = margin, + openPayAmount = openPayAmount, + openPayAssetId = openPayAssetId, state = state, markPrice = markPrice, unrealizedPnl = unrealizedPnl, @@ -20,4 +22,3 @@ fun PerpsPositionItem.toPosition(): PerpsPosition { updatedAt = updatedAt, ) } - diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index f36edfda08..4fd8733d5c 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -7,64 +7,102 @@ import com.google.gson.annotations.SerializedName @Entity(tableName = "markets") data class PerpsMarket( - @PrimaryKey - @SerializedName("market_id") + @PrimaryKey @SerializedName("market_id") @ColumnInfo(name = "market_id") val marketId: String, - @SerializedName("market") - @ColumnInfo(name = "market") - val market: String, - @SerializedName("symbol") - @ColumnInfo(name = "symbol") - val symbol: String, + @SerializedName("display_symbol") @ColumnInfo(name = "display_symbol") val displaySymbol: String, + @SerializedName("token_symbol") @ColumnInfo(name = "token_symbol") val tokenSymbol: String, + + @SerializedName("quote_symbol") + @ColumnInfo(name = "quote_symbol") + val quoteSymbol: String, + @SerializedName("mark_price") @ColumnInfo(name = "mark_price") val markPrice: String, + + @SerializedName("leverage") + @ColumnInfo(name = "leverage") + val leverage: Int, + + @SerializedName("icon_url") + @ColumnInfo(name = "icon_url") + val iconUrl: String, + @SerializedName("funding_rate") @ColumnInfo(name = "funding_rate") val fundingRate: String, - @SerializedName("maker_fee") - @ColumnInfo(name = "maker_fee") - val makerFee: String, - @SerializedName("taker_fee") - @ColumnInfo(name = "taker_fee") - val takerFee: String, + @SerializedName("min_order_size") @ColumnInfo(name = "min_order_size") val minOrderSize: String, + @SerializedName("max_order_size") @ColumnInfo(name = "max_order_size") val maxOrderSize: String, + @SerializedName("min_order_value") @ColumnInfo(name = "min_order_value") val minOrderValue: String, - @SerializedName("quantity_increment") - @ColumnInfo(name = "quantity_increment") - val quantityIncrement: String, - @SerializedName("price_increment") - @ColumnInfo(name = "price_increment") - val priceIncrement: String, + + @SerializedName("max_order_value") + @ColumnInfo(name = "max_order_value") + val maxOrderValue: String, + @SerializedName("last") @ColumnInfo(name = "last") val last: String, + @SerializedName("volume") @ColumnInfo(name = "volume") val volume: String, - @SerializedName("leverage") - @ColumnInfo(name = "leverage") - val leverage: Int, - @SerializedName("icon_url") - @ColumnInfo(name = "icon_url") - val iconUrl: String, + + @SerializedName("amount") + @ColumnInfo(name = "amount") + val amount: String, + + @SerializedName("high") + @ColumnInfo(name = "high") + val high: String, + + @SerializedName("low") + @ColumnInfo(name = "low") + val low: String, + + @SerializedName("open") + @ColumnInfo(name = "open") + val open: String, + @SerializedName("change") @ColumnInfo(name = "change") val change: String, + + @SerializedName("bid_price") + @ColumnInfo(name = "bid_price") + val bidPrice: String, + + @SerializedName("ask_price") + @ColumnInfo(name = "ask_price") + val askPrice: String, + + @SerializedName("trade_count") + @ColumnInfo(name = "trade_count") + val tradeCount: Int, + + @SerializedName("first_trade_id") + @ColumnInfo(name = "first_trade_id") + val firstTradeId: Long, + + @SerializedName("created_at") + @ColumnInfo(name = "created_at") + val createdAt: String, + @SerializedName("updated_at") @ColumnInfo(name = "updated_at") val updatedAt: String, diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt index eb478b32c5..62d1ec0d2d 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt @@ -14,9 +14,9 @@ data class PerpsPosition( @SerializedName("position_id") @ColumnInfo(name = "position_id") val positionId: String, - @SerializedName("product_id") - @ColumnInfo(name = "product_id") - val productId: String, + @SerializedName(value = "market_id", alternate = ["product_id"]) + @ColumnInfo(name = "market_id") + val marketId: String, @SerializedName("side") @ColumnInfo(name = "side") val side: String, @@ -38,6 +38,12 @@ data class PerpsPosition( @SerializedName("margin") @ColumnInfo(name = "margin") val margin: String? = null, + @SerializedName("open_pay_amount") + @ColumnInfo(name = "open_pay_amount") + val openPayAmount: String? = null, + @SerializedName("open_pay_asset_id") + @ColumnInfo(name = "open_pay_asset_id") + val openPayAssetId: String? = null, @SerializedName("state") @ColumnInfo(name = "state") val state: String? = null, diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt index b2719749df..b6d76d810c 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt @@ -17,9 +17,9 @@ data class PerpsPositionHistory( @SerializedName("position_id") @ColumnInfo(name = "position_id") val positionId: String, - @SerializedName("product_id") - @ColumnInfo(name = "product_id") - val productId: String, + @SerializedName(value = "market_id", alternate = ["product_id"]) + @ColumnInfo(name = "market_id") + val marketId: String, @SerializedName("market_symbol") @ColumnInfo(name = "market_symbol") val marketSymbol: String? = null, diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt index cf7e88af3e..164406d733 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt @@ -13,12 +13,9 @@ data class PerpsPositionHistoryItem( @SerializedName("position_id") @ColumnInfo(name = "position_id") val positionId: String, - @SerializedName("product_id") - @ColumnInfo(name = "product_id") - val productId: String, - @SerializedName("symbol") - @ColumnInfo(name = "symbol") - val symbol: String? = null, + @SerializedName(value = "market_id", alternate = ["product_id"]) + @ColumnInfo(name = "market_id") + val marketId: String, @SerializedName("side") @ColumnInfo(name = "side") val side: String, diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt index 21084d719b..d277be4867 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt @@ -10,9 +10,9 @@ data class PerpsPositionItem( @SerializedName("position_id") @ColumnInfo(name = "position_id") val positionId: String, - @SerializedName("product_id") - @ColumnInfo(name = "product_id") - val productId: String, + @SerializedName(value = "market_id", alternate = ["product_id"]) + @ColumnInfo(name = "market_id") + val marketId: String, @SerializedName("side") @ColumnInfo(name = "side") val side: String, @@ -34,6 +34,12 @@ data class PerpsPositionItem( @SerializedName("margin") @ColumnInfo(name = "margin") val margin: String? = null, + @SerializedName("open_pay_amount") + @ColumnInfo(name = "open_pay_amount") + val openPayAmount: String? = null, + @SerializedName("open_pay_asset_id") + @ColumnInfo(name = "open_pay_asset_id") + val openPayAssetId: String? = null, @SerializedName("state") @ColumnInfo(name = "state") val state: String? = null, @@ -60,4 +66,4 @@ data class PerpsPositionItem( val iconUrl: String? = null, @ColumnInfo(name = "token_symbol") val tokenSymbol: String? = null, -) : Parcelable \ No newline at end of file +) : Parcelable 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 ade34ea9a9..e305afd3e7 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 @@ -360,14 +360,14 @@ interface RouteService { @Query("limit") limit: Int = 20 ): MixinResponse> - @GET("perps/market") + @GET("perps/markets/{market_id}") suspend fun getPerpsMarket( - @Query("market_id") marketId: String + @Path("market_id") marketId: String ): MixinResponse @GET("perps/markets/candles") suspend fun getPerpsCandles( - @Query("product") product: String, + @Query("market_id") marketId: String, @Query("time_frame") timeFrame: String ): MixinResponse diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt index a6bf75601a..f19fd0c178 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt @@ -21,7 +21,15 @@ interface PerpsMarketDao : BaseDao { @Query("SELECT * FROM markets WHERE market_id = :marketId") suspend fun getMarket(marketId: String): PerpsMarket? - @Query("SELECT * FROM markets WHERE symbol LIKE '%' || :query || '%' ORDER BY CAST(volume AS REAL) DESC") + @Query( + """ + SELECT * FROM markets + WHERE display_symbol LIKE '%' || :query || '%' + OR token_symbol LIKE '%' || :query || '%' + OR quote_symbol LIKE '%' || :query || '%' + ORDER BY CAST(volume AS REAL) DESC + """ + ) suspend fun searchMarkets(query: String): List @Query("DELETE FROM markets") diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt index ce629d91b2..d7ca51f801 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -21,7 +21,7 @@ interface PerpsPositionDao : BaseDao { @Query(""" SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p - LEFT JOIN markets m ON m.market_id = p.product_id + LEFT JOIN markets m ON m.market_id = p.market_id WHERE p.wallet_id = :walletId AND (p.state = 'open' or p.state = 'opening') ORDER BY p.created_at DESC """) @@ -31,7 +31,7 @@ interface PerpsPositionDao : BaseDao { """ SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p - LEFT JOIN markets m ON m.market_id = p.product_id + LEFT JOIN markets m ON m.market_id = p.market_id WHERE p.wallet_id = :walletId AND (p.state = 'open' or p.state = 'opening') ORDER BY p.created_at DESC """ @@ -41,7 +41,7 @@ interface PerpsPositionDao : BaseDao { @Query(""" SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p - LEFT JOIN markets m ON m.market_id = p.product_id + LEFT JOIN markets m ON m.market_id = p.market_id WHERE p.wallet_id = :walletId AND (p.state = 'open' or p.state = 'opening') ORDER BY p.created_at DESC """) @@ -50,7 +50,7 @@ interface PerpsPositionDao : BaseDao { @Query(""" SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p - LEFT JOIN markets m ON m.market_id = p.product_id + LEFT JOIN markets m ON m.market_id = p.market_id WHERE p.position_id = :positionId """) suspend fun getPosition(positionId: String): PerpsPositionItem? @@ -58,7 +58,7 @@ interface PerpsPositionDao : BaseDao { @Query(""" SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol FROM positions p - LEFT JOIN markets m ON m.market_id = p.product_id + LEFT JOIN markets m ON m.market_id = p.market_id WHERE p.position_id = :positionId """) fun observePosition(positionId: String): Flow diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt index fb79c6c9f1..cc5785c18e 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -19,9 +19,9 @@ interface PerpsPositionHistoryDao : BaseDao { suspend fun insertAll(histories: List) @Query(""" - SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol, m.symbol + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol FROM position_history h - LEFT JOIN markets m ON m.market_id = h.product_id + LEFT JOIN markets m ON m.market_id = h.market_id WHERE h.wallet_id = :walletId ORDER BY h.closed_at DESC LIMIT :limit @@ -30,9 +30,9 @@ interface PerpsPositionHistoryDao : BaseDao { @Query( """ - SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol, m.symbol + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol FROM position_history h - LEFT JOIN markets m ON m.market_id = h.product_id + LEFT JOIN markets m ON m.market_id = h.market_id WHERE h.wallet_id = :walletId ORDER BY h.closed_at DESC LIMIT :limit @@ -41,18 +41,18 @@ interface PerpsPositionHistoryDao : BaseDao { fun observeHistories(walletId: String, limit: Int): Flow> @Query(""" - SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol, m.symbol + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol FROM position_history h - LEFT JOIN markets m ON m.market_id = h.product_id + LEFT JOIN markets m ON m.market_id = h.market_id WHERE h.wallet_id = :walletId ORDER BY h.closed_at DESC """) fun getHistoriesPaged(walletId: String): DataSource.Factory @Query(""" - SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol, m.symbol + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol FROM position_history h - LEFT JOIN markets m ON m.market_id = h.product_id + LEFT JOIN markets m ON m.market_id = h.market_id WHERE h.history_id = :historyId """) suspend fun getHistory(historyId: String): PerpsPositionHistoryItem? 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 6732c266ee..3c6bf5892c 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 @@ -1095,7 +1095,7 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { PerpsActivity.showDetail( requireContext(), market.marketId, - market.symbol, + market.displaySymbol, market.displaySymbol, market.tokenSymbol ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index ce94945f7b..2abc730f0e 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -64,7 +64,7 @@ import kotlin.math.min @Composable fun CandleChart( - symbol: String, + marketId: String, timeFrame: String ) { val context = LocalContext.current @@ -73,11 +73,11 @@ fun CandleChart( var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } - LaunchedEffect(symbol, timeFrame) { + LaunchedEffect(marketId, timeFrame) { isLoading = true errorMessage = null viewModel.loadCandles( - symbol = symbol, + marketId = marketId, timeFrame = timeFrame, onSuccess = { data -> candles = data 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 81c0e74ba3..2d99b3580a 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 @@ -396,7 +396,7 @@ class TradeFragment : BaseFragment() { PerpsActivity.showDetail( requireContext(), market.marketId, - market.symbol, + market.displaySymbol, market.displaySymbol, market.tokenSymbol ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt index a5e7a55803..53b52764ac 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt @@ -78,7 +78,7 @@ class AllPerpsMarketsFragment : BaseFragment() { PerpsActivity.showDetail( requireContext(), market.marketId, - market.symbol, + market.displaySymbol, market.displaySymbol, market.tokenSymbol ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index fdb6a49c97..ca21b3ed0f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -41,21 +41,21 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions companion object { const val TAG = "AllPositionsFragment" - private const val ARGS_INITIAL_TAB = "args_initial_tab" - private const val TAB_OPEN = "tab_open" - private const val TAB_CLOSED = "tab_closed" + private const val ARGS_POSITION_TYPE = "args_position_type" + private const val TYPE_OPEN = "type_open" + private const val TYPE_CLOSED = "type_closed" private const val POSITION_REFRESH_INTERVAL_MS = 10_000L private const val CLOSED_POSITION_REFRESH_LIMIT = 100 - fun newInstance(initialOpenTab: Boolean = false) = AllPositionsFragment().apply { + fun newInstance(showOpenPositions: Boolean = false) = AllPositionsFragment().apply { arguments = Bundle().apply { - putString(ARGS_INITIAL_TAB, if (initialOpenTab) TAB_OPEN else TAB_CLOSED) + putString(ARGS_POSITION_TYPE, if (showOpenPositions) TYPE_OPEN else TYPE_CLOSED) } } - fun newOpenInstance() = newInstance(initialOpenTab = true) + fun newOpenInstance() = newInstance(showOpenPositions = true) - fun newClosedInstance() = newInstance(initialOpenTab = false) + fun newClosedInstance() = newInstance(showOpenPositions = false) } @Inject @@ -72,13 +72,13 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions OpenPositionAdapter(isQuoteColorReversed) { position -> lifecycleScope.launch { val market = withContext(Dispatchers.IO) { - perpsMarketDao.getMarket(position.productId) + perpsMarketDao.getMarket(position.marketId) } activity?.let { ctx -> PerpsActivity.showDetail( context = ctx, - marketId = position.productId, - marketSymbol = market?.symbol ?: "", + marketId = position.marketId, + marketSymbol = market?.displaySymbol ?: "", marketDisplaySymbol = market?.displaySymbol ?: "", marketTokenSymbol = market?.tokenSymbol ?: "" ) @@ -102,12 +102,12 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions } } - private enum class PositionTab { + private enum class PositionType { OPEN, CLOSED } - private var currentTab: PositionTab = PositionTab.CLOSED + private var positionType: PositionType = PositionType.CLOSED private var openPositionsLiveData: LiveData>? = null private var closedPositionsLiveData: LiveData>? = null private var totalValueJob: Job? = null @@ -146,27 +146,16 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions titleView.rightAnimator.setOnClickListener { context?.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) } - titleView.setSubTitle(getString(R.string.Closed_Positions), "") - - positionsRv.layoutManager = LinearLayoutManager(requireContext()) - - radioGroup.setOnCheckedChangeListener { _, checkedId -> - currentTab = if (checkedId == R.id.radio_open) { - PositionTab.OPEN - } else { - PositionTab.CLOSED - } - loadPositions() + positionType = when (arguments?.getString(ARGS_POSITION_TYPE, TYPE_CLOSED)) { + TYPE_OPEN -> PositionType.OPEN + else -> PositionType.CLOSED } + titleView.setSubTitle( + getString(if (positionType == PositionType.OPEN) R.string.perps_positions else R.string.perps_activity), + "" + ) - val initialTab = arguments?.getString(ARGS_INITIAL_TAB, TAB_CLOSED) - currentTab = if (initialTab == TAB_OPEN) { - radioOpen.isChecked = true - PositionTab.OPEN - } else { - radioClosed.isChecked = true - PositionTab.CLOSED - } + positionsRv.layoutManager = LinearLayoutManager(requireContext()) } loadPositions() @@ -183,13 +172,13 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions lastClosedTotalPnl = 0.0 lastClosedTotalEntryValue = 0.0 - if (currentTab == PositionTab.OPEN) { - binding.titleView.setSubTitle(getString(R.string.Open_Positions_Simple), "") + if (positionType == PositionType.OPEN) { + binding.titleView.setSubTitle(getString(R.string.perps_positions), "") totalValueAdapter.submitTitle(R.string.Total_Position_Value) binding.positionsRv.adapter = ConcatAdapter(totalValueAdapter, openPositionAdapter) loadOpenPositions() } else { - binding.titleView.setSubTitle(getString(R.string.Closed_Positions), "") + binding.titleView.setSubTitle(getString(R.string.perps_activity), "") totalValueAdapter.submitTitle(R.string.Realized_PnL) binding.positionsRv.adapter = ConcatAdapter(totalValueAdapter, closedPositionAdapter) loadClosedPositions() 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 ffe6b1d52f..0a6f9a9414 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 @@ -558,7 +558,7 @@ fun OpenPositionPage( scope.launch { val hasOpeningPosition = viewModel.getOpenPositionsFromDb(walletId) - .any { it.productId == m.marketId } + .any { it.marketId == m.marketId } if (hasOpeningPosition) { errorInfo = context.getString(R.string.error_waiting_other_orders) return@launch @@ -566,12 +566,11 @@ fun OpenPositionPage( viewModel.openPerpsOrder( assetId = token.assetId, - productId = m.marketId, + marketId = m.marketId, side = if (isLong) "long" else "short", amount = orderValue.stripTrailingZeros().toPlainString(), leverage = leverage.toInt(), walletId = walletId, - marketSymbol = m.symbol, entryPrice = m.markPrice, onSuccess = { response -> errorInfo = null @@ -583,7 +582,7 @@ fun OpenPositionPage( leverage = leverage.toInt(), entryPrice = m.markPrice, tokenSymbol = token.symbol, - payUrl = response.payUrl + payUrl = response.paymentUrl ).setOnDone { onBack() }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt index 42cbcb6d8b..58b4356cf1 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 @@ -264,7 +264,7 @@ fun PerpetualContent( OpenPositionItem( position = position, onClick = { - val targetMarket = markets.firstOrNull { it.marketId == position.productId } + val targetMarket = markets.firstOrNull { it.marketId == position.marketId } if (targetMarket != null) { onMarketItemClick(targetMarket) } else { @@ -378,7 +378,7 @@ fun PerpetualContent( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(R.string.Closed_Positions), + text = stringResource(R.string.perps_activity), fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) 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 323591f1e2..5030fb359d 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 @@ -154,7 +154,7 @@ class PerpetualViewModel @Inject constructor( } fun loadCandles( - symbol: String, + marketId: String, timeFrame: String, onSuccess: (List) -> Unit, onError: (String) -> Unit @@ -162,7 +162,7 @@ class PerpetualViewModel @Inject constructor( viewModelScope.launch { try { val response = withContext(Dispatchers.IO) { - routeService.getPerpsCandles(symbol, timeFrame) + routeService.getPerpsCandles(marketId, timeFrame) } val data = response.data @@ -241,13 +241,12 @@ class PerpetualViewModel @Inject constructor( fun openPerpsOrder( assetId: String, - productId: String, + marketId: String, side: String, amount: String, leverage: Int, walletId: String, destination: String? = null, - marketSymbol: String, entryPrice: String, onSuccess: (OpenOrderResponse) -> Unit, onError: (Int, String) -> Unit @@ -256,7 +255,7 @@ class PerpetualViewModel @Inject constructor( try { val request = OpenOrderRequest( assetId = assetId, - productId = productId, + marketId = marketId, side = side, amount = amount, leverage = leverage, @@ -270,17 +269,19 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { - Timber.d("Perps order opened: ${data.orderId}, payUrl: ${data.payUrl}") + Timber.d("Perps order opened: ${data.orderId}, payUrl: ${data.paymentUrl}") val position = PerpsPosition( positionId = data.orderId, - productId = productId, + marketId = marketId, side = side, quantity = amount, settleAssetId = assetId, botId = "", entryPrice = entryPrice, margin = amount, + openPayAmount = data.payAmount, + openPayAssetId = assetId, leverage = leverage, state = "pending", markPrice = entryPrice, @@ -515,7 +516,7 @@ class PerpetualViewModel @Inject constructor( val positions = withContext(Dispatchers.IO) { perpsPositionDao.getOpenPositions(walletId) } - val position = positions.firstOrNull { it.productId == marketId } + val position = positions.firstOrNull { it.marketId == marketId } onSuccess(position) } catch (e: Exception) { Timber.e(e, "Error loading position by market") @@ -530,7 +531,7 @@ class PerpetualViewModel @Inject constructor( val allHistories = withContext(Dispatchers.IO) { perpsPositionHistoryDao.getHistories(walletId, 100) } - val filteredHistories = allHistories.filter { it.productId == marketId } + val filteredHistories = allHistories.filter { it.marketId == marketId } onSuccess(filteredHistories) } catch (e: Exception) { Timber.e(e, "Error loading closed positions by market") 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 84cfcca7a0..600f95b029 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 @@ -197,7 +197,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen botId = position.botId ) - viewModel.getMarketFromDb(position.productId)?.let { market -> + viewModel.getMarketFromDb(position.marketId)?.let { market -> marketIconUrl = market.iconUrl marketSymbol = market.displaySymbol } @@ -211,14 +211,14 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen latestRoe = position.roe ?: "0" lifecycleScope.launch { - viewModel.getMarketFromDb(position.productId)?.let { market -> + viewModel.getMarketFromDb(position.marketId)?.let { market -> marketIconUrl = market.iconUrl marketSymbol = market.displaySymbol } } viewModel.loadMarketDetail( - marketId = position.productId, + marketId = position.marketId, onSuccess = { market -> marketIconUrl = market.iconUrl marketSymbol = market.displaySymbol 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 219a8f616e..3ed61b1c2b 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 @@ -4,6 +4,7 @@ import PageScaffold import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -97,15 +98,17 @@ fun PerpsMarketDetailPage( flowOf(emptyList()) } }.collectAsStateWithLifecycle(initialValue = emptyList()) - val currentPosition = openPositions.firstOrNull { it.productId == marketId } - val closedPositions = allClosedPositions.filter { it.productId == marketId } - - val timeFrameValues = listOf("1h", "1d", "1w", "1M") + val currentPosition = openPositions.firstOrNull { it.marketId == marketId } + val closedPositions = allClosedPositions.filter { it.marketId == marketId } + val timeFrameValues = listOf("1m", "5m", "15m", "1h", "4h", "1d", "1w") val timeFrameLabels = listOf( + stringResource(R.string.minutes_count_short, 1), + stringResource(R.string.minutes_count_short, 5), + stringResource(R.string.minutes_count_short, 15), stringResource(R.string.hours_count_short, 1), + stringResource(R.string.hours_count_short, 4), stringResource(R.string.days_count_short, 1), stringResource(R.string.weeks_count_short, 1), - stringResource(R.string.months_count_short, 1), ) val quoteColorReversed = context.defaultSharedPreferences .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) @@ -161,7 +164,7 @@ fun PerpsMarketDetailPage( if (market != null) { MarketDetailCard( market = market!!, - marketSymbol = marketSymbol, + marketId = marketId, displaySymbol = displaySymbol, tokenSymbol = tokenSymbol, selectedTimeFrame = selectedTimeFrame, @@ -361,19 +364,8 @@ private fun MarketInfoCard( market: PerpsMarket, onLearnClick: () -> Unit, ) { - val context = LocalContext.current - val quoteColorReversed = context.defaultSharedPreferences - .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) - val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen - val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed val fiatRate = BigDecimal(Fiats.getRate()) val fiatSymbol = Fiats.getSymbol() - val fundingRate = market.fundingRate.toBigDecimalOrNull() ?: BigDecimal.ZERO - val fundingColor = when { - fundingRate > BigDecimal.ZERO -> risingColor - fundingRate < BigDecimal.ZERO -> fallingColor - else -> MixinAppTheme.colors.textPrimary - } Column( modifier = Modifier @@ -429,20 +421,18 @@ private fun MarketInfoCard( ) Spacer(modifier = Modifier.height(12.dp)) - Column { - Text( - text = stringResource(R.string.Funding_Rate), - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${market.fundingRate}%", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MixinAppTheme.colors.textPrimary - ) - } + Text( + text = stringResource(R.string.Funding_Rate), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = market.fundingRate, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) } } @@ -462,7 +452,7 @@ private fun formatVolume( @Composable private fun MarketDetailCard( market: PerpsMarket, - marketSymbol: String, + marketId: String, displaySymbol: String, tokenSymbol: String, selectedTimeFrame: Int, @@ -546,7 +536,7 @@ private fun MarketDetailCard( .clipToBounds() ) { CandleChart( - symbol = marketSymbol, + marketId = marketId, timeFrame = timeFrameValues[selectedTimeFrame] ) } @@ -554,13 +544,14 @@ private fun MarketDetailCard( Spacer(modifier = Modifier.height(16.dp)) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { timeFrameLabels.forEachIndexed { index, timeFrameLabel -> Box( modifier = Modifier - .weight(1f) .height(36.dp) .clip(RoundedCornerShape(18.dp)) .then( @@ -570,7 +561,8 @@ private fun MarketDetailCard( Modifier } ) - .clickable { onTimeFrameChange(index) }, + .clickable { onTimeFrameChange(index) } + .padding(horizontal = 12.dp), contentAlignment = Alignment.Center ) { Text( @@ -828,7 +820,7 @@ private fun ClosedPositionsSection( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.Closed_Positions), + text = stringResource(R.string.perps_activity), fontSize = 16.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt index fa86e2c26e..2edb36f6f2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt @@ -102,7 +102,8 @@ class PerpsMarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment( } else { val filtered = allMarkets.filter { market -> market.displaySymbol.contains(query, ignoreCase = true) || - market.symbol.contains(query, ignoreCase = true) + market.tokenSymbol.contains(query, ignoreCase = true) || + market.quoteSymbol.contains(query, ignoreCase = true) } updateList(filtered) } @@ -117,7 +118,7 @@ class PerpsMarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment( PerpsActivity.showOpenPosition( context = requireContext(), marketId = market.marketId, - marketSymbol = market.symbol, + marketSymbol = market.displaySymbol, marketDisplaySymbol = market.displaySymbol, marketTokenSymbol = market.tokenSymbol, isLong = isLong diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt index 8195748fc1..e05e51ccc5 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -148,8 +148,8 @@ class PositionDetailFragment : BaseFragment() { private fun openTradeAgain(positionHistory: PerpsPositionHistoryItem) { PerpsActivity.showDetail( context = requireContext(), - marketId = positionHistory.productId, - marketSymbol = positionHistory.symbol.orEmpty(), + marketId = positionHistory.marketId, + marketSymbol = positionHistory.displaySymbol.orEmpty(), marketDisplaySymbol = positionHistory.displaySymbol.orEmpty(), marketTokenSymbol = positionHistory.tokenSymbol.orEmpty() ) diff --git a/app/src/main/res/layout/fragment_all_closed_positions.xml b/app/src/main/res/layout/fragment_all_closed_positions.xml index bb96566b06..2f10842776 100644 --- a/app/src/main/res/layout/fragment_all_closed_positions.xml +++ b/app/src/main/res/layout/fragment_all_closed_positions.xml @@ -11,46 +11,6 @@ android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent" /> - - - - - - - - + app:layout_constraintTop_toBottomOf="@id/title_view" /> + app:layout_constraintTop_toBottomOf="@id/title_view" /> + app:layout_constraintTop_toBottomOf="@id/title_view" /> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9b080b6782..cd493711eb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -577,6 +577,7 @@ %d 小时 %d 小时 + %1$d分钟 %1$d小时 %1$d天 %1$d周 @@ -2329,9 +2330,10 @@ %1$d倍 持仓中 持仓中(%1$d) - 已平仓 + 持仓 + 历史记录 暂无持仓 - 暂无已平仓记录 + 暂无历史记录 盈亏 方向 暂无行情 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f688226dd8..ded4028427 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -591,6 +591,7 @@ %d Hour %d Hours + %1$dm %1$dH %1$dD %1$dW @@ -2396,10 +2397,11 @@ %1$s$%2$f %1$s(%2$.2f%%) Open Positions - Open Positions(%1$d) - Closed Positions + Positions(%1$d) + Positions + Activity No Positions - No Closed Positions + No Activities PNL Direction No Markets From 4f15c7d4fd3455cc44575decddd20d14baaf1b8e Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 11 Mar 2026 09:58:30 +0800 Subject: [PATCH 066/105] Update --- .../android/ui/home/web3/trade/CandleChart.kt | 20 +++++--- .../web3/trade/TotalPositionValueAdapter.kt | 48 ++++++++++++------- .../web3/trade/perps/AllPositionsFragment.kt | 3 +- .../web3/trade/perps/PerpetualViewModel.kt | 4 ++ .../web3/trade/perps/PerpsMarketDetailPage.kt | 21 ++++++-- .../web3/trade/perps/PositionDetailPage.kt | 19 ++++---- app/src/main/res/values-zh-rCN/strings.xml | 3 +- app/src/main/res/values/strings.xml | 1 - gradle/gradle-daemon-jvm.properties | 12 ----- 9 files changed, 75 insertions(+), 56 deletions(-) delete mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index 2abc730f0e..e604c3b0eb 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -55,10 +55,11 @@ import one.mixin.android.api.response.perps.CandleView import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.ui.home.web3.trade.perps.PerpetualViewModel +import org.threeten.bp.Instant +import org.threeten.bp.ZoneId +import org.threeten.bp.ZonedDateTime +import org.threeten.bp.format.DateTimeFormatter import java.math.BigDecimal -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale import kotlin.math.max import kotlin.math.min @@ -590,15 +591,20 @@ private fun formatPrice(price: BigDecimal): String { private fun formatCandleTime(timestamp: Long, timeFrame: String): String { val millis = if (timestamp < 1_000_000_000_000L) timestamp * 1000 else timestamp + val instant = Instant.ofEpochMilli(millis) + val localeZone = ZoneId.systemDefault() + val zonedDateTime = instant.atZone(localeZone) + val pattern = when (timeFrame.lowercase()) { - "1h" -> "MM-dd HH:mm" - "1d" -> "yyyy-MM-dd HH:mm" + "1m", "5m", "15m" -> "MM-dd HH:mm" + "1h", "4h" -> "MM-dd HH:mm" + "1d" -> "yyyy-MM-dd" "1w" -> "yyyy-MM-dd" - "1m" -> "yyyy-MM" else -> "MM-dd HH:mm" } + return runCatching { - SimpleDateFormat(pattern, Locale.getDefault()).format(Date(millis)) + zonedDateTime.format(DateTimeFormatter.ofPattern(pattern).withZone(localeZone)) }.getOrDefault("--") } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index 6bb3a463c5..30615d2079 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -6,6 +6,8 @@ import android.view.ViewGroup import android.widget.TextView import androidx.annotation.AttrRes import androidx.annotation.StringRes +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import one.mixin.android.R import one.mixin.android.extension.priceFormat @@ -15,7 +17,7 @@ import java.math.BigDecimal class TotalPositionValueAdapter : RecyclerView.Adapter() { private var totalValue: BigDecimal = BigDecimal.ZERO private var subValue: BigDecimal = BigDecimal.ZERO - private var subPercent: BigDecimal = BigDecimal.ZERO + private var subPercent: BigDecimal? = null private var isClosed: Boolean = false @StringRes private var titleResId: Int = R.string.Total_Position_Value @@ -25,7 +27,7 @@ class TotalPositionValueAdapter : RecyclerView.Adapter BigDecimal.ZERO -> gainColor - subtitlePercent < BigDecimal.ZERO -> lossColor - else -> resolveAttrColor(itemView, R.attr.text_assist) + if (subtitlePercent == null) { + subtitleTv.isGone = true + } else { + subtitleTv.isVisible = true + subtitleTv.text = context.getString( + R.string.Perpetual_Amount_Percent_Format, + formatSignedUsd(subtitleValue), + subtitlePercent.toDouble() + ) + subtitleTv.setTextColor( + when { + subtitlePercent > BigDecimal.ZERO -> gainColor + subtitlePercent < BigDecimal.ZERO -> lossColor + else -> resolveAttrColor(itemView, R.attr.text_assist) + } + ) } - ) - - subtitleTv.text = context.getString( - R.string.Perpetual_Amount_Percent_Format, - formatSignedUsd(subtitleValue), - subtitlePercent.toDouble() - ) + } } private fun formatSignedUsd(amount: BigDecimal): String { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index ca21b3ed0f..afdfd9e9b9 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -252,9 +252,8 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions if (lastClosedTotalPnl != totalPnl || lastClosedTotalEntryValue != totalEntryValue) { lastClosedTotalPnl = totalPnl lastClosedTotalEntryValue = totalEntryValue - val percent = calculatePercent(totalPnl, totalEntryValue) totalValueAdapter.submitTotal(BigDecimal.valueOf(totalPnl)) - totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.valueOf(percent)) + totalValueAdapter.submitSubtitle(BigDecimal.valueOf(totalPnl), BigDecimal.ZERO) } } } 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 5030fb359d..d3c64cfd79 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 @@ -340,6 +340,10 @@ class PerpetualViewModel @Inject constructor( return tokenDao.assetItemFlowByChainAndSymbol(chainId, symbol) } + fun observeTokenByAssetId(assetId: String): Flow { + return tokenDao.assetItemFlow(assetId) + } + fun refreshSinglePosition(positionId: String, walletId: String? = null) { viewModelScope.launch { try { 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 3ed61b1c2b..db2b7178fe 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 @@ -192,7 +192,10 @@ fun PerpsMarketDetailPage( Spacer(modifier = Modifier.height(16.dp)) if (currentPosition != null) { - OpenPositionCard(position = currentPosition) + OpenPositionCard( + position = currentPosition, + viewModel = viewModel + ) Spacer(modifier = Modifier.height(16.dp)) } @@ -584,6 +587,7 @@ private fun MarketDetailCard( @Composable private fun OpenPositionCard( position: PerpsPositionItem, + viewModel: PerpetualViewModel, ) { val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences @@ -601,8 +605,17 @@ private fun OpenPositionCard( val directionColor = if (isLong) risingColor else fallingColor val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO - val markPrice = position.markPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO - val orderValue = quantity.multiply(markPrice).multiply(fiatRate) + val openPayToken by remember(position.openPayAssetId) { + if (position.openPayAssetId.isNullOrEmpty()) { + flowOf(null) + } else { + viewModel.observeTokenByAssetId(position.openPayAssetId) + } + }.collectAsStateWithLifecycle(initialValue = null) + val openPayAmount = position.openPayAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val openPayPrice = openPayToken?.priceUsd?.toBigDecimalOrNull() + ?: if (position.openPayAssetId in Constants.usdIds) BigDecimal.ONE else BigDecimal.ZERO + val amountValue = openPayAmount.multiply(openPayPrice).multiply(fiatRate) val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO val liquidationPrice = calculateLiquidationPriceValue(entryPrice, position.leverage, isLong) @@ -741,7 +754,7 @@ private fun OpenPositionCard( // ) } Text( - text = "${fiatSymbol}${orderValue.priceFormat()}", + text = "${fiatSymbol}${amountValue.priceFormat()}", fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary ) 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 bc1bcaaccc..a86396e201 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 @@ -43,10 +43,11 @@ import one.mixin.android.extension.priceFormat import one.mixin.android.ui.tip.wc.compose.ItemWalletContent import one.mixin.android.ui.wallet.alert.components.cardBackground import one.mixin.android.vo.Fiats +import org.threeten.bp.Instant +import org.threeten.bp.ZoneId +import org.threeten.bp.format.DateTimeFormatter import java.math.BigDecimal import java.math.RoundingMode -import java.text.SimpleDateFormat -import java.util.Locale @Composable fun PositionDetailPage( @@ -57,14 +58,13 @@ fun PositionDetailPage( onShare: (() -> Unit)? = null, onSupport: (() -> Unit)? = null, ) { - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()) fun formatDate(dateStr: String?): String { if (dateStr == null) return "" return try { - val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - val date = inputFormat.parse(dateStr) - date?.let { dateFormat.format(it) } ?: dateStr + val instant = Instant.parse(dateStr) + instant.atZone(ZoneId.systemDefault()).format(dateFormat) } catch (e: Exception) { dateStr } @@ -357,14 +357,13 @@ fun PositionDetailPage( onShare: (() -> Unit)? = null, onSupport: (() -> Unit)? = null, ) { - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + val dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()) fun formatDate(dateStr: String?): String { if (dateStr == null) return "" return try { - val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) - val date = inputFormat.parse(dateStr) - date?.let { dateFormat.format(it) } ?: dateStr + val instant = Instant.parse(dateStr) + instant.atZone(ZoneId.systemDefault()).format(dateFormat) } catch (e: Exception) { dateStr } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cd493711eb..c9bfe32056 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2328,8 +2328,7 @@ 订单总价值 %1$s %2$s %1$d倍 - 持仓中 - 持仓中(%1$d) + 持仓(%1$d) 持仓 历史记录 暂无持仓 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ded4028427..8c0421fd88 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2396,7 +2396,6 @@ $%1$f %1$s$%2$f %1$s(%2$.2f%%) - Open Positions Positions(%1$d) Positions Activity diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties deleted file mode 100644 index 6c1139ec06..0000000000 --- a/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,12 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect -toolchainVersion=21 From 4cd52865a6c4ea3d5e7a2397a16e3f73ff6de160 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 11 Mar 2026 13:43:40 +0800 Subject: [PATCH 067/105] Update strings --- .../web3/trade/perps/PerpetualGuidePage.kt | 28 +++++++++---------- .../PerpsConfirmBottomSheetDialogFragment.kt | 2 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 2 +- .../web3/trade/perps/PositionDetailPage.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 7 ----- app/src/main/res/values/strings.xml | 7 ----- 6 files changed, 17 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 9935ebbbe3..40dcdf9ecf 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -86,10 +86,10 @@ fun PerpetualGuidePage( val coroutineScope = rememberCoroutineScope() val tabs = listOf( stringResource(R.string.Perpetual_Guide_Overview), - stringResource(R.string.Perpetual_Guide_Long), - stringResource(R.string.Perpetual_Guide_Short), - stringResource(R.string.Perpetual_Guide_Leverage), - stringResource(R.string.Perpetual_Guide_Position) + stringResource(R.string.Long), + stringResource(R.string.Short), + stringResource(R.string.Leverage), + stringResource(R.string.Order_Value) ) val safeInitialTab = initialTab.coerceIn(0, tabs.lastIndex) var selectedTab by remember(safeInitialTab) { mutableIntStateOf(safeInitialTab) } @@ -198,11 +198,11 @@ private fun LongContent() { iconRes = R.drawable.ic_chain_btc ), GuideRowData( - label = stringResource(R.string.Perpetual_Direction), + label = stringResource(R.string.Direction), value = stringResource(R.string.Long) ), GuideRowData( - label = stringResource(R.string.Perpetual_Leverage_Times), + label = stringResource(R.string.Leverage), value = "${leverage}x" ), GuideRowData( @@ -254,11 +254,11 @@ private fun ShortContent() { iconRes = R.drawable.ic_chain_eth ), GuideRowData( - label = stringResource(R.string.Perpetual_Direction), + label = stringResource(R.string.Direction), value = stringResource(R.string.Short) ), GuideRowData( - label = stringResource(R.string.Perpetual_Leverage_Times), + label = stringResource(R.string.Leverage), value = "${leverage}x" ), GuideRowData( @@ -313,11 +313,11 @@ private fun LeverageContent() { iconRes = R.drawable.ic_chain_sol ), GuideRowData( - label = stringResource(R.string.Perpetual_Direction), + label = stringResource(R.string.Direction), value = stringResource(R.string.Long) ), GuideRowData( - label = stringResource(R.string.Perpetual_Leverage_Times), + label = stringResource(R.string.Leverage), value = "${leverage}x" ), GuideRowData( @@ -395,11 +395,11 @@ private fun PositionContent() { iconRes = R.drawable.ic_chain_sol ), GuideRowData( - label = stringResource(R.string.Perpetual_Direction), + label = stringResource(R.string.Direction), value = stringResource(R.string.Long) ), GuideRowData( - label = stringResource(R.string.Perpetual_Leverage_Times), + label = stringResource(R.string.Leverage), value = "${leverage}x" ), GuideRowData( @@ -653,8 +653,8 @@ private fun ExampleWithScenariosCard( ) } } - val directionLabel = stringResource(R.string.Perpetual_Direction) - val leverageLabel = stringResource(R.string.Perpetual_Leverage_Times) + val directionLabel = stringResource(R.string.Direction) + val leverageLabel = stringResource(R.string.Leverage) val investmentLabel = stringResource(R.string.Perpetual_Investment) val longDirection = stringResource(R.string.Long) val shortDirection = stringResource(R.string.Short) 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 a3c550ab83..3b2bdf0104 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 @@ -349,7 +349,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Box(modifier = Modifier.height(20.dp)) PerpsInfoItem( - title = stringResource(R.string.Perpetual_Direction).uppercase(), + title = stringResource(R.string.Direction).uppercase(), value = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${leverage}x" ) Box(modifier = Modifier.height(6.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index db2b7178fe..0d3319d71c 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 @@ -633,7 +633,7 @@ private fun OpenPositionCard( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.Perpetual_Guide_Position), + text = stringResource(R.string.Order_Value), fontSize = 16.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary 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 a86396e201..2233d95f46 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 @@ -182,7 +182,7 @@ fun PositionDetailPage( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.Perpetual_Guide_Close), + text = stringResource(R.string.Close_Position), color = MixinAppTheme.colors.textPrimary, fontWeight = FontWeight.W500, modifier = Modifier diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c9bfe32056..d96a7fe208 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2229,10 +2229,6 @@ 共管的 Safe 金库不计入统计 交易说明 简介 - 做多 - 做空 - 杠杆 - 平仓 <%1$s %1$s> 永续合约允许您使用杠杆交易加密货币,从而放大您的潜在利润(和损失)。您可以做多(押注价格上涨)或做空(押注价格下跌),而无需拥有标的资产。 @@ -2240,7 +2236,6 @@ 做空意味着您预期价格会下跌。如果价格下跌,您就会获利。如果价格上涨,您就会亏损。您的盈亏会被杠杆倍数放大。 杠杆允许您用更少的资金控制更大的仓位。例如,使用 10 倍杠杆,1%% 的价格变动会导致 10%% 的盈亏。杠杆越高,风险越大。 您可以随时平仓以实现盈亏。平仓价格基于当前市场价格。请务必监控您的仓位以避免爆仓。 - 订单价值 具体说明 Mixin 永续合约是一种以数字资产结算的衍生品交易方式,支持做多和做空,无到期日。通过杠杆机制,交易者可以放大仓位,把握价格上涨或下跌带来的交易机会。 产品特点 @@ -2255,12 +2250,10 @@ 请合理控制杠杆倍数与仓位规模,谨慎交易。 举例说明 交易对 - 开仓方向 价格上涨 %1$s%% → 盈利 %2$s%% (+%3$s) 价格下跌 %1$s%% → 盈利 %2$s%% (+%3$s) 价格下跌 %1$s%% → 亏损 -%2$s %3$s 价格上涨 %1$s%% → 亏损 -%2$s %3$s - 杠杆倍数 投入资金 场景一:价格上涨 场景二:价格下跌 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c0421fd88..4d03d1168b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2288,10 +2288,6 @@ Co-managed safes are excluded from the total Trading Guide Overview - Long - Short - Leverage - Close Position <%1$s %1$s> Perpetual contracts allow you to trade cryptocurrency with leverage, enabling you to amplify your potential profits (and losses). You can go long (bet on price increase) or short (bet on price decrease) without owning the underlying asset. @@ -2299,7 +2295,6 @@ Going short means you expect the price to decrease. If the price goes down, you profit. If it goes up, you lose. Your profit/loss is multiplied by your leverage. Leverage allows you to control a larger position with less capital. For example, with 10x leverage, a 1%% price move results in a 10%% profit or loss. Higher leverage means higher risk. You can close your position at any time to realize your profit or loss. The closing price is based on the current market price. Make sure to monitor your positions to avoid liquidation. - Order Value Instructions Mixin perpetual contracts are derivative trading instruments settled in digital assets, supporting long and short positions with no expiration date. Through leverage, traders can amplify positions to capture trading opportunities from price movements. Product Features @@ -2314,12 +2309,10 @@ Please control leverage and position size reasonably and trade cautiously. Example Trading Pair - Direction Price up %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Loss -%2$s %3$s Price up %1$s%% → Loss -%2$s %3$s - Leverage Investment Scenario 1: Price Rise Scenario 2: Price Fall From b4c1befb5b4e92cb0837335f2db0ed44c5f18b66 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 11 Mar 2026 15:05:21 +0800 Subject: [PATCH 068/105] Update page --- .../web3/trade/TotalPositionValueAdapter.kt | 18 +++++++++++++++++- .../web3/trade/perps/AllPositionsFragment.kt | 11 ++++++++++- .../web3/trade/perps/PerpsMarketDetailPage.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 9 +++++---- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index 30615d2079..2c3a22ee49 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -86,7 +86,23 @@ class TotalPositionValueAdapter : RecyclerView.Adapter%1$d倍 持仓(%1$d) 持仓 + 持仓 历史记录 暂无持仓 暂无历史记录 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d03d1168b..c273123495 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -483,7 +483,7 @@ ERROR 10614: The input amount is either too small or too large, please adjust the amount. ERROR 10614: The amount exceeds the maximum allowable value of %1$s. Please adjust the amount. Try placing a limit order? No restrictions on amount or token. ERROR 10615: This trading pair is currently not supported, please try switching to a different token. - ERROR 10650: Order value is too small. + ERROR 10650: Size is too small. ERROR 10651: The current market already has an open position. The current market already has an open position. ERROR 20114: Expired phone verification code @@ -2333,7 +2333,7 @@ Leverage amplifies both gains and losses. Higher leverage means greater P&L fluctuation with price movements. Please choose leverage reasonably, with high leverage, even small price movements can lead to significant losses. - The order value of current contract is calculated by "Margin × Leverage", representing the asset size controlled in this transaction. + The size of current contract is calculated by "Margin × Leverage", representing the asset size controlled in this transaction. Usage Determines the market exposure size of this transaction. Affects the amplification factor of profit and loss changes. @@ -2346,7 +2346,7 @@ Leverage Select Token Select Leverage - Order Value + Size Liquidation Price Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) Long @@ -2383,7 +2383,7 @@ Close Long Close Short Estimated Liquidation Price - Total Order Value + Total Size %1$s %2$s %1$dx $%1$f @@ -2391,6 +2391,7 @@ %1$s(%2$.2f%%) Positions(%1$d) Positions + Position Activity No Positions No Activities From 4eb6625a72b8cfd1ad974c00d07f82af7f74c604 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 12 Mar 2026 09:39:43 +0800 Subject: [PATCH 069/105] Update strings --- .../home/web3/trade/TotalPositionValueAdapter.kt | 15 ++++++++------- .../ui/home/web3/trade/perps/OpenPositionPage.kt | 2 +- .../PerpsConfirmBottomSheetDialogFragment.kt | 2 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 10 ++++++++-- app/src/main/res/values-zh-rCN/strings.xml | 5 +++-- app/src/main/res/values/strings.xml | 3 ++- 6 files changed, 23 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index 2c3a22ee49..1ba85a7303 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -67,17 +67,17 @@ class TotalPositionValueAdapter : RecyclerView.Adapter= BigDecimal.ZERO valueTv.setTextColor( if (isProfit) { @@ -104,6 +104,7 @@ class TotalPositionValueAdapter : RecyclerView.Adapter杠杆 选择代币 选择杠杆 - 订单价值 + 规模 清算价格 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 @@ -2318,7 +2318,8 @@ 平仓做多 平仓做空 预估清算价格 - 订单总价值 + 持仓总价值 + 保证金 %1$s %2$s %1$d倍 持仓(%1$d) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c273123495..1dabbb7319 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,6 +90,7 @@ Allow bot to add, modify and delete your circles. Allow bot to send messages represent of you. Amount + Margin an audio Anonymous Number ANSWER @@ -2383,7 +2384,7 @@ Close Long Close Short Estimated Liquidation Price - Total Size + Total Position Value %1$s %2$s %1$dx $%1$f From f4cf59f984f39dbc0a38de55416637580b491099 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 12 Mar 2026 14:58:42 +0800 Subject: [PATCH 070/105] fix: update perpetual order value copy to position size --- .../android/ui/home/web3/trade/perps/OpenPositionPage.kt | 2 +- .../android/ui/home/web3/trade/perps/PerpetualGuidePage.kt | 4 ++-- .../ui/home/web3/trade/perps/PerpsMarketDetailPage.kt | 2 +- .../android/ui/home/web3/trade/perps/PositionDetailPage.kt | 4 ++-- app/src/main/res/values-zh-rCN/strings.xml | 6 +++--- app/src/main/res/values/strings.xml | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) 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 fee37d8c42..ad7ce83edc 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 @@ -469,7 +469,7 @@ fun OpenPositionPage( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row (verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(R.string.Order_Value), + text = stringResource(R.string.Position_Size), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 40dcdf9ecf..d4c02ead61 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -89,7 +89,7 @@ fun PerpetualGuidePage( stringResource(R.string.Long), stringResource(R.string.Short), stringResource(R.string.Leverage), - stringResource(R.string.Order_Value) + stringResource(R.string.Position_Size) ) val safeInitialTab = initialTab.coerceIn(0, tabs.lastIndex) var selectedTab by remember(safeInitialTab) { mutableIntStateOf(safeInitialTab) } @@ -407,7 +407,7 @@ private fun PositionContent() { value = "${formatGuideInt(investment)} USDT" ), GuideRowData( - label = stringResource(R.string.Order_Value), + label = stringResource(R.string.Position_Size), value = orderValueText ) ), 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 00cecb6a5c..7b7855f790 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 @@ -712,7 +712,7 @@ private fun OpenPositionCard( Column { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(R.string.Order_Value), + text = stringResource(R.string.Position_Size), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) 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 2233d95f46..6c3f6f5835 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 @@ -234,7 +234,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.Order_Value).uppercase(), + label = stringResource(R.string.Position_Size).uppercase(), value = "${String.format("%f", absQuantity)} ${position.tokenSymbol ?: ""}", subtitle = formatFiat(orderValue) ) @@ -564,7 +564,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.Order_Value).uppercase(), + label = stringResource(R.string.Position_Size).uppercase(), value = "${String.format("%f", absQuantity)} ${positionHistory.tokenSymbol ?: ""}", subtitle = formatFiat(orderValue) ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9bed8b68c3..bf5c4a7605 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -470,7 +470,7 @@ 错误 10614: 输入金额太小或太大,请重新输入。 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 - 错误 10650: 订单价值太小,请调整后重试。 + 错误 10650: 仓位规模太小,请调整后重试。 错误 10651:当前市场已开仓。 当前市场已开仓。 错误 20114:验证码已过期 @@ -2274,7 +2274,7 @@ 杠杆会同时放大收益和亏损。 杠杆倍数越高,盈亏随价格波动的变化越大。 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 - 当前合约的订单价值由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 + 当前合约的仓位规模由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 用途 决定本次交易的市场敞口规模。 影响盈亏变化的放大倍数。 @@ -2287,7 +2287,7 @@ 杠杆 选择代币 选择杠杆 - 规模 + 规模 清算价格 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd56945f82..e8a3ea95c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -484,7 +484,7 @@ ERROR 10614: The input amount is either too small or too large, please adjust the amount. ERROR 10614: The amount exceeds the maximum allowable value of %1$s. Please adjust the amount. Try placing a limit order? No restrictions on amount or token. ERROR 10615: This trading pair is currently not supported, please try switching to a different token. - ERROR 10650: Size is too small. + ERROR 10650: Position size is too small. ERROR 10651: The current market already has an open position. The current market already has an open position. ERROR 20114: Expired phone verification code @@ -2334,7 +2334,7 @@ Leverage amplifies both gains and losses. Higher leverage means greater P&L fluctuation with price movements. Please choose leverage reasonably, with high leverage, even small price movements can lead to significant losses. - The size of current contract is calculated by "Margin × Leverage", representing the asset size controlled in this transaction. + The position size of the current contract is calculated by "Margin × Leverage", representing the asset size controlled in this transaction. Usage Determines the market exposure size of this transaction. Affects the amplification factor of profit and loss changes. @@ -2347,7 +2347,7 @@ Leverage Select Token Select Leverage - Size + Position Size Liquidation Price Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) Long From 6e1778e28f3ecb8767a7e1f0d055c5792411fd52 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 12 Mar 2026 16:38:23 +0800 Subject: [PATCH 071/105] fix: refresh perps position history from latest local offset, updates --- .../db/perps/PerpsPositionHistoryDao.kt | 9 ++++- .../web3/trade/perps/AllPositionsFragment.kt | 14 +++++++ .../home/web3/trade/perps/PerpetualContent.kt | 37 ++++++++++++++++++- .../web3/trade/perps/PerpetualViewModel.kt | 13 ++++--- .../res/layout/layout_empty_transaction.xml | 13 ++++++- app/src/main/res/values-zh-rCN/strings.xml | 10 ++--- app/src/main/res/values/strings.xml | 10 ++--- 7 files changed, 87 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt index cc5785c18e..eb5ce9a74d 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -23,10 +23,11 @@ interface PerpsPositionHistoryDao : BaseDao { FROM position_history h LEFT JOIN markets m ON m.market_id = h.market_id WHERE h.wallet_id = :walletId + AND (:offset IS NULL OR h.closed_at < :offset) ORDER BY h.closed_at DESC LIMIT :limit """) - suspend fun getHistories(walletId: String, limit: Int): List + suspend fun getHistories(walletId: String, limit: Int, offset: String? = null): List @Query( """ @@ -34,11 +35,12 @@ interface PerpsPositionHistoryDao : BaseDao { FROM position_history h LEFT JOIN markets m ON m.market_id = h.market_id WHERE h.wallet_id = :walletId + AND (:offset IS NULL OR h.closed_at < :offset) ORDER BY h.closed_at DESC LIMIT :limit """ ) - fun observeHistories(walletId: String, limit: Int): Flow> + fun observeHistories(walletId: String, limit: Int, offset: String? = null): Flow> @Query(""" SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol @@ -60,6 +62,9 @@ interface PerpsPositionHistoryDao : BaseDao { @Query("DELETE FROM position_history WHERE wallet_id = :walletId") suspend fun deleteByWallet(walletId: String) + @Query("SELECT MAX(closed_at) FROM position_history WHERE wallet_id = :walletId") + suspend fun getLatestClosedAt(walletId: String): String? + @Query("SELECT SUM(CAST(realized_pnl AS REAL)) FROM position_history WHERE wallet_id = :walletId") suspend fun getTotalRealizedPnl(walletId: String): Double? diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index 4f80fa68bc..698a4c102d 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -122,6 +122,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions openPositionAdapter.submitList(pagedList) val isEmpty = pagedList.isEmpty() binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Positions) + binding.emptyView.helpAction.isVisible = isEmpty binding.emptyView.root.isVisible = isEmpty } @@ -130,6 +131,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions closedPositionAdapter.submitList(pagedList) val isEmpty = pagedList.isEmpty() binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Closed_Positions) + binding.emptyView.helpAction.isVisible = false binding.emptyView.root.isVisible = isEmpty } @@ -156,6 +158,16 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions ) positionsRv.layoutManager = LinearLayoutManager(requireContext()) + emptyView.helpAction.setOnClickListener { + parentFragmentManager.beginTransaction() + .add( + android.R.id.content, + PerpetualGuideFragment.newInstance(initialTab = PerpetualGuideFragment.TAB_OVERVIEW), + PerpetualGuideFragment.TAG + ) + .addToBackStack(null) + .commit() + } } loadPositions() @@ -193,6 +205,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions binding.progressBar.isVisible = true binding.emptyView.root.isVisible = false + binding.emptyView.helpAction.isVisible = false totalValueAdapter.submitTotal(BigDecimal.ZERO) totalValueAdapter.submitSubtitle(BigDecimal.ZERO, BigDecimal.ZERO) @@ -209,6 +222,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions binding.progressBar.isVisible = true binding.emptyView.root.isVisible = false + binding.emptyView.helpAction.isVisible = false totalValueAdapter.submitTotal(BigDecimal.ZERO) totalValueAdapter.submitSubtitle(BigDecimal.ZERO, BigDecimal.ZERO) 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 58b4356cf1..6ec780a36d 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -424,7 +426,40 @@ fun PerpetualContent( } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .clickable { onShowTradingGuide() } + .padding(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image(painter = painterResource(id = R.drawable.ic_perps_help), contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.How_Perps_Works), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + Text( + text = stringResource(R.string.Learn_How_To_Trade_Perps), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) } Row( 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 d3c64cfd79..dde2b3a5f8 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 @@ -50,14 +50,17 @@ class PerpetualViewModel @Inject constructor( jobManager.addJobInBackground(RefreshPerpsPositionsJob(walletId)) } - fun refreshPositionHistory(walletId: String, limit: Int = 100, offset: String? = null) { + fun refreshPositionHistory(walletId: String, limit: Int = 100) { viewModelScope.launch { try { + val latestClosedAt = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getLatestClosedAt(walletId) + } val response = withContext(Dispatchers.IO) { routeService.getPerpsPositionHistory( walletId = walletId, limit = limit, - offset = offset + offset = latestClosedAt ) } @@ -653,7 +656,7 @@ class PerpetualViewModel @Inject constructor( viewModelScope.launch { try { val cachedHistories = withContext(Dispatchers.IO) { - perpsPositionHistoryDao.getHistories(walletId, limit) + perpsPositionHistoryDao.getHistories(walletId, limit, offset) } if (cachedHistories.isNotEmpty()) { @@ -679,7 +682,7 @@ class PerpetualViewModel @Inject constructor( } val updatedHistories = withContext(Dispatchers.IO) { - perpsPositionHistoryDao.getHistories(walletId, limit) + perpsPositionHistoryDao.getHistories(walletId, limit, offset) } onSuccess(updatedHistories) } else { @@ -694,7 +697,7 @@ class PerpetualViewModel @Inject constructor( Timber.e(e, error) val cachedHistories = withContext(Dispatchers.IO) { - perpsPositionHistoryDao.getHistories(walletId, limit) + perpsPositionHistoryDao.getHistories(walletId, limit, offset) } if (cachedHistories.isEmpty()) { onError(error) diff --git a/app/src/main/res/layout/layout_empty_transaction.xml b/app/src/main/res/layout/layout_empty_transaction.xml index 2acf54477e..b7536ff76b 100644 --- a/app/src/main/res/layout/layout_empty_transaction.xml +++ b/app/src/main/res/layout/layout_empty_transaction.xml @@ -29,4 +29,15 @@ android:layout_marginTop="@dimen/margin16" android:text="@string/No_transactions" android:textColor="?attr/text_assist" /> - \ No newline at end of file + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index bf5c4a7605..df747d7f14 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2249,12 +2249,12 @@ 当保证金不足时,仓位可能被强制平仓。 请合理控制杠杆倍数与仓位规模,谨慎交易。 举例说明 - 交易对 + 开仓交易 价格上涨 %1$s%% → 盈利 %2$s%% (+%3$s) 价格下跌 %1$s%% → 盈利 %2$s%% (+%3$s) 价格下跌 %1$s%% → 亏损 -%2$s %3$s 价格上涨 %1$s%% → 亏损 -%2$s %3$s - 投入资金 + 保证金 场景一:价格上涨 场景二:价格下跌 场景%1$d:%2$s @@ -2284,10 +2284,10 @@ 价格剧烈波动可能会快速消耗投入资金。 永续合约 开仓 - 杠杆 + 杠杆倍数 选择代币 选择杠杆 - 规模 + 仓位规模 清算价格 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 @@ -2329,7 +2329,7 @@ 暂无持仓 暂无历史记录 盈亏 - 方向 + 开仓方向 暂无行情 查看更多 加载中... diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8a3ea95c4..32f6e2b84b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2309,12 +2309,12 @@ When margin is insufficient, positions may be forcibly liquidated. Please control leverage and position size reasonably and trade cautiously. Example - Trading Pair + Opening Trade Price up %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Loss -%2$s %3$s Price up %1$s%% → Loss -%2$s %3$s - Investment + Margin Scenario 1: Price Rise Scenario 2: Price Fall Scenario %1$d: %2$s @@ -2344,10 +2344,10 @@ Severe price volatility may rapidly consume investment. Perpetual Open Position - Leverage + Leverage Multiplier Select Token Select Leverage - Position Size + Position Scale Liquidation Price Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) Long @@ -2397,7 +2397,7 @@ No Positions No Activities PNL - Direction + Opening Direction No Markets View More Loading... From bf4f914821d098329744381a46b100510b2be8a7 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 12 Mar 2026 17:16:33 +0800 Subject: [PATCH 072/105] Update scheme --- .../ui/conversation/link/LinkBottomSheetDialogFragment.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt index 3c6bf5892c..0b00b41bfc 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 @@ -1080,13 +1080,13 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { val type = uri.getQueryParameter("type") if (type.equals("perps", true)) { - val productId = uri.getQueryParameter("product") - if (productId.isNullOrBlank() || !productId.isUUID()) { + val marketId = uri.getQueryParameter("market") + if (marketId.isNullOrBlank() || !marketId.isUUID()) { showError(R.string.Invalid_payment_link) return } - val market = linkViewModel.getPerpsMarket(productId) + val market = linkViewModel.getPerpsMarket(marketId) if (market == null) { showError(R.string.Data_error) return From 305ec369292b81ec1e0bcf69aec0afef4df35113 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 12 Mar 2026 18:20:35 +0800 Subject: [PATCH 073/105] Update database schema --- .../one.mixin.android.db.PerpsDatabase/1.json | 116 ++++++++---------- .../api/request/perps/OpenOrderRequest.kt | 2 +- .../android/api/response/perps/PerpsExt.kt | 24 ++-- .../android/api/response/perps/PerpsMarket.kt | 8 -- .../api/response/perps/PerpsPosition.kt | 46 +++---- .../response/perps/PerpsPositionHistory.kt | 11 +- .../perps/PerpsPositionHistoryItem.kt | 6 +- .../api/response/perps/PerpsPositionItem.kt | 2 +- .../db/perps/PerpsPositionHistoryDao.kt | 45 ++++--- .../web3/trade/perps/PerpetualViewModel.kt | 36 +++--- 10 files changed, 132 insertions(+), 164 deletions(-) diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index fa8a9e0de8..d34fa61ff4 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "f17028ee96c6829315578a046a8e4856", + "identityHash": "05fceb901a48fd5e06c755017d38bf94", "entities": [ { "tableName": "positions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `settle_asset_id` TEXT, `bot_id` TEXT, `margin` TEXT, `open_pay_amount` TEXT, `open_pay_asset_id` TEXT, `state` TEXT, `mark_price` TEXT, `unrealized_pnl` TEXT, `roe` TEXT, `wallet_id` TEXT, `created_at` TEXT, `updated_at` TEXT, PRIMARY KEY(`position_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `margin` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `state` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `unrealized_pnl` TEXT NOT NULL, `roe` TEXT NOT NULL, `settle_asset_id` TEXT NOT NULL, `open_pay_amount` TEXT NOT NULL, `open_pay_asset_id` TEXT NOT NULL, `bot_id` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`position_id`))", "fields": [ { "fieldPath": "positionId", @@ -38,71 +38,83 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "leverage", - "columnName": "leverage", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "settleAssetId", - "columnName": "settle_asset_id", - "affinity": "TEXT" - }, - { - "fieldPath": "botId", - "columnName": "bot_id", - "affinity": "TEXT" - }, { "fieldPath": "margin", "columnName": "margin", - "affinity": "TEXT" - }, - { - "fieldPath": "openPayAmount", - "columnName": "open_pay_amount", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true }, { - "fieldPath": "openPayAssetId", - "columnName": "open_pay_asset_id", - "affinity": "TEXT" + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true }, { "fieldPath": "state", "columnName": "state", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true }, { "fieldPath": "markPrice", "columnName": "mark_price", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true }, { "fieldPath": "unrealizedPnl", "columnName": "unrealized_pnl", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true }, { "fieldPath": "roe", "columnName": "roe", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "settleAssetId", + "columnName": "settle_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPayAmount", + "columnName": "open_pay_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "openPayAssetId", + "columnName": "open_pay_asset_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "botId", + "columnName": "bot_id", + "affinity": "TEXT", + "notNull": true }, { "fieldPath": "walletId", "columnName": "wallet_id", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true }, { "fieldPath": "createdAt", "columnName": "created_at", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true }, { "fieldPath": "updatedAt", "columnName": "updated_at", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true } ], "primaryKey": { @@ -113,8 +125,8 @@ } }, { - "tableName": "position_history", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`history_id` TEXT NOT NULL, `position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `market_symbol` TEXT, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `close_price` TEXT NOT NULL, `realized_pnl` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `margin_method` TEXT, `open_at` TEXT NOT NULL, `closed_at` TEXT NOT NULL, `wallet_id` TEXT NOT NULL, PRIMARY KEY(`history_id`))", + "tableName": "position_histories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`history_id` TEXT NOT NULL, `position_id` TEXT NOT NULL, `market_id` TEXT NOT NULL, `side` TEXT NOT NULL, `quantity` TEXT NOT NULL, `entry_price` TEXT NOT NULL, `close_price` TEXT NOT NULL, `realized_pnl` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `margin_method` TEXT NOT NULL, `open_at` TEXT NOT NULL, `closed_at` TEXT NOT NULL, PRIMARY KEY(`history_id`))", "fields": [ { "fieldPath": "historyId", @@ -134,11 +146,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "marketSymbol", - "columnName": "market_symbol", - "affinity": "TEXT" - }, { "fieldPath": "side", "columnName": "side", @@ -178,7 +185,8 @@ { "fieldPath": "marginMethod", "columnName": "margin_method", - "affinity": "TEXT" + "affinity": "TEXT", + "notNull": true }, { "fieldPath": "openAt", @@ -191,12 +199,6 @@ "columnName": "closed_at", "affinity": "TEXT", "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "wallet_id", - "affinity": "TEXT", - "notNull": true } ], "primaryKey": { @@ -208,7 +210,7 @@ }, { "tableName": "markets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `max_order_value` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `amount` TEXT NOT NULL, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `trade_count` INTEGER NOT NULL, `first_trade_id` INTEGER NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `max_order_value` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `amount` TEXT NOT NULL, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", "fields": [ { "fieldPath": "marketId", @@ -336,18 +338,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "tradeCount", - "columnName": "trade_count", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "firstTradeId", - "columnName": "first_trade_id", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "createdAt", "columnName": "created_at", @@ -371,7 +361,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f17028ee96c6829315578a046a8e4856')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '05fceb901a48fd5e06c755017d38bf94')" ] } -} \ No newline at end of file +} 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 ecfb99efb4..136bfd85f0 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 @@ -25,7 +25,7 @@ data class OpenOrderResponse( @SerializedName("payment_url") val paymentUrl: String?, @SerializedName("pay_amount") - val payAmount: String?, + val payAmount: String, @SerializedName("deposit_destination") val depositDestination: String?, @SerializedName("app_id") diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt index 3ec049dfaf..895712cabe 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt @@ -8,17 +8,17 @@ fun PerpsPositionItem.toPosition(): PerpsPosition { quantity = quantity, entryPrice = entryPrice, leverage = leverage, - settleAssetId = settleAssetId, - botId = botId, - margin = margin, - openPayAmount = openPayAmount, - openPayAssetId = openPayAssetId, - state = state, - markPrice = markPrice, - unrealizedPnl = unrealizedPnl, - roe = roe, - walletId = walletId, - createdAt = createdAt, - updatedAt = updatedAt, + settleAssetId = settleAssetId ?: "", + botId = botId ?: "", + margin = margin ?: "0", + openPayAmount = openPayAmount ?: "0", + openPayAssetId = openPayAssetId ?: "", + state = state ?: "", + markPrice = markPrice ?: "0", + unrealizedPnl = unrealizedPnl ?: "0", + roe = roe ?: "0", + walletId = walletId ?: "", + createdAt = createdAt ?: "", + updatedAt = updatedAt ?: "", ) } diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index 4fd8733d5c..803764683e 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -91,14 +91,6 @@ data class PerpsMarket( @ColumnInfo(name = "ask_price") val askPrice: String, - @SerializedName("trade_count") - @ColumnInfo(name = "trade_count") - val tradeCount: Int, - - @SerializedName("first_trade_id") - @ColumnInfo(name = "first_trade_id") - val firstTradeId: Long, - @SerializedName("created_at") @ColumnInfo(name = "created_at") val createdAt: String, diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt index 62d1ec0d2d..200f97b5a7 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt @@ -14,7 +14,7 @@ data class PerpsPosition( @SerializedName("position_id") @ColumnInfo(name = "position_id") val positionId: String, - @SerializedName(value = "market_id", alternate = ["product_id"]) + @SerializedName(value = "market_id") @ColumnInfo(name = "market_id") val marketId: String, @SerializedName("side") @@ -26,42 +26,42 @@ data class PerpsPosition( @SerializedName("entry_price") @ColumnInfo(name = "entry_price") val entryPrice: String, + @SerializedName("margin") + @ColumnInfo(name = "margin") + val margin: String, @SerializedName("leverage") @ColumnInfo(name = "leverage") val leverage: Int, - @SerializedName("settle_asset_id") - @ColumnInfo(name = "settle_asset_id") - val settleAssetId: String? = null, - @SerializedName("bot_id") - @ColumnInfo(name = "bot_id") - val botId: String? = null, - @SerializedName("margin") - @ColumnInfo(name = "margin") - val margin: String? = null, - @SerializedName("open_pay_amount") - @ColumnInfo(name = "open_pay_amount") - val openPayAmount: String? = null, - @SerializedName("open_pay_asset_id") - @ColumnInfo(name = "open_pay_asset_id") - val openPayAssetId: String? = null, @SerializedName("state") @ColumnInfo(name = "state") - val state: String? = null, + val state: String, @SerializedName("mark_price") @ColumnInfo(name = "mark_price") - val markPrice: String? = null, + val markPrice: String, @SerializedName("unrealized_pnl") @ColumnInfo(name = "unrealized_pnl") - val unrealizedPnl: String? = null, + val unrealizedPnl: String, @SerializedName("roe") @ColumnInfo(name = "roe") - val roe: String? = null, + val roe: String, + @SerializedName("settle_asset_id") + @ColumnInfo(name = "settle_asset_id") + val settleAssetId: String, + @SerializedName("open_pay_amount") + @ColumnInfo(name = "open_pay_amount") + val openPayAmount: String, + @SerializedName("open_pay_asset_id") + @ColumnInfo(name = "open_pay_asset_id") + val openPayAssetId: String, + @SerializedName("bot_id") + @ColumnInfo(name = "bot_id") + val botId: String, @ColumnInfo(name = "wallet_id") - val walletId: String? = null, + val walletId: String, @SerializedName("created_at") @ColumnInfo(name = "created_at") - val createdAt: String? = null, + val createdAt: String, @SerializedName("updated_at") @ColumnInfo(name = "updated_at") - val updatedAt: String? = null, + val updatedAt: String, ) : Parcelable diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt index b6d76d810c..f72b67691d 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt @@ -8,7 +8,7 @@ import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize @Parcelize -@Entity(tableName = "position_history") +@Entity(tableName = "position_histories") data class PerpsPositionHistory( @PrimaryKey @SerializedName("history_id") @@ -17,12 +17,9 @@ data class PerpsPositionHistory( @SerializedName("position_id") @ColumnInfo(name = "position_id") val positionId: String, - @SerializedName(value = "market_id", alternate = ["product_id"]) + @SerializedName(value = "market_id") @ColumnInfo(name = "market_id") val marketId: String, - @SerializedName("market_symbol") - @ColumnInfo(name = "market_symbol") - val marketSymbol: String? = null, @SerializedName("side") @ColumnInfo(name = "side") val side: String, @@ -43,13 +40,11 @@ data class PerpsPositionHistory( val leverage: Int, @SerializedName("margin_method") @ColumnInfo(name = "margin_method") - val marginMethod: String? = null, + val marginMethod: String, @SerializedName("open_at") @ColumnInfo(name = "open_at") val openAt: String, @SerializedName("closed_at") @ColumnInfo(name = "closed_at") val closedAt: String, - @ColumnInfo(name = "wallet_id") - val walletId: String = "" ) : Parcelable diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt index 164406d733..21ae0ff553 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt @@ -13,7 +13,7 @@ data class PerpsPositionHistoryItem( @SerializedName("position_id") @ColumnInfo(name = "position_id") val positionId: String, - @SerializedName(value = "market_id", alternate = ["product_id"]) + @SerializedName(value = "market_id") @ColumnInfo(name = "market_id") val marketId: String, @SerializedName("side") @@ -36,15 +36,13 @@ data class PerpsPositionHistoryItem( val leverage: Int, @SerializedName("margin_method") @ColumnInfo(name = "margin_method") - val marginMethod: String? = null, + val marginMethod: String, @SerializedName("open_at") @ColumnInfo(name = "open_at") val openAt: String, @SerializedName("closed_at") @ColumnInfo(name = "closed_at") val closedAt: String, - @ColumnInfo(name = "wallet_id") - val walletId: String, @ColumnInfo(name = "display_symbol") val displaySymbol: String? = null, @ColumnInfo(name = "icon_url") diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt index d277be4867..4609974584 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt @@ -10,7 +10,7 @@ data class PerpsPositionItem( @SerializedName("position_id") @ColumnInfo(name = "position_id") val positionId: String, - @SerializedName(value = "market_id", alternate = ["product_id"]) + @SerializedName(value = "market_id") @ColumnInfo(name = "market_id") val marketId: String, @SerializedName("side") diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt index eb5ce9a74d..021c606ca7 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -20,60 +20,57 @@ interface PerpsPositionHistoryDao : BaseDao { @Query(""" SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol - FROM position_history h + FROM position_histories h LEFT JOIN markets m ON m.market_id = h.market_id - WHERE h.wallet_id = :walletId - AND (:offset IS NULL OR h.closed_at < :offset) + WHERE (:offset IS NULL OR h.closed_at < :offset) ORDER BY h.closed_at DESC LIMIT :limit """) - suspend fun getHistories(walletId: String, limit: Int, offset: String? = null): List + suspend fun getHistories(limit: Int, offset: String? = null): List @Query( """ SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol - FROM position_history h + FROM position_histories h LEFT JOIN markets m ON m.market_id = h.market_id - WHERE h.wallet_id = :walletId - AND (:offset IS NULL OR h.closed_at < :offset) + WHERE (:offset IS NULL OR h.closed_at < :offset) ORDER BY h.closed_at DESC LIMIT :limit """ ) - fun observeHistories(walletId: String, limit: Int, offset: String? = null): Flow> + fun observeHistories(limit: Int, offset: String? = null): Flow> @Query(""" SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol - FROM position_history h + FROM position_histories h LEFT JOIN markets m ON m.market_id = h.market_id - WHERE h.wallet_id = :walletId ORDER BY h.closed_at DESC """) - fun getHistoriesPaged(walletId: String): DataSource.Factory + fun getHistoriesPaged(): DataSource.Factory @Query(""" SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol - FROM position_history h + FROM position_histories h LEFT JOIN markets m ON m.market_id = h.market_id WHERE h.history_id = :historyId """) suspend fun getHistory(historyId: String): PerpsPositionHistoryItem? - @Query("DELETE FROM position_history WHERE wallet_id = :walletId") - suspend fun deleteByWallet(walletId: String) + @Query("DELETE FROM position_histories") + suspend fun deleteAll() - @Query("SELECT MAX(closed_at) FROM position_history WHERE wallet_id = :walletId") - suspend fun getLatestClosedAt(walletId: String): String? + @Query("SELECT MAX(closed_at) FROM position_histories") + suspend fun getLatestClosedAt(): String? - @Query("SELECT SUM(CAST(realized_pnl AS REAL)) FROM position_history WHERE wallet_id = :walletId") - suspend fun getTotalRealizedPnl(walletId: String): Double? + @Query("SELECT SUM(CAST(realized_pnl AS REAL)) FROM position_histories") + suspend fun getTotalRealizedPnl(): Double? - @Query("SELECT COALESCE(SUM(CAST(realized_pnl AS REAL)), 0) FROM position_history WHERE wallet_id = :walletId") - fun observeTotalRealizedPnl(walletId: String): Flow + @Query("SELECT COALESCE(SUM(CAST(realized_pnl AS REAL)), 0) FROM position_histories") + fun observeTotalRealizedPnl(): Flow - @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM position_history WHERE wallet_id = :walletId") - suspend fun getTotalClosedEntryValue(walletId: String): Double? + @Query("SELECT SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))) FROM position_histories") + suspend fun getTotalClosedEntryValue(): Double? - @Query("SELECT COALESCE(SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))), 0) FROM position_history WHERE wallet_id = :walletId") - fun observeTotalClosedEntryValue(walletId: String): Flow + @Query("SELECT COALESCE(SUM(CAST(entry_price AS REAL) * ABS(CAST(quantity AS REAL))), 0) FROM position_histories") + fun observeTotalClosedEntryValue(): Flow } 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 dde2b3a5f8..b310e72a2b 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 @@ -54,7 +54,7 @@ class PerpetualViewModel @Inject constructor( viewModelScope.launch { try { val latestClosedAt = withContext(Dispatchers.IO) { - perpsPositionHistoryDao.getLatestClosedAt(walletId) + perpsPositionHistoryDao.getLatestClosedAt() } val response = withContext(Dispatchers.IO) { routeService.getPerpsPositionHistory( @@ -66,11 +66,10 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { - val histories = data.map { it.copy(walletId = walletId) } withContext(Dispatchers.IO) { - perpsPositionHistoryDao.insertAll(histories) + perpsPositionHistoryDao.insertAll(data) } - Timber.d("Perps position history refreshed: ${histories.size} items") + Timber.d("Perps position history refreshed: ${data.size} items") } else { Timber.e("Failed to refresh position history: ${response.errorDescription}") } @@ -379,7 +378,7 @@ class PerpetualViewModel @Inject constructor( } fun observeClosedPositions(walletId: String, limit: Int): Flow> { - return perpsPositionHistoryDao.observeHistories(walletId, limit) + return perpsPositionHistoryDao.observeHistories(limit) } suspend fun getOpenPositionsFromDb(walletId: String): List { @@ -396,7 +395,7 @@ class PerpetualViewModel @Inject constructor( suspend fun getClosedPositionsFromDb(walletId: String, limit: Int): List { return withContext(Dispatchers.IO) { try { - perpsPositionHistoryDao.getHistories(walletId, limit) + perpsPositionHistoryDao.getHistories(limit) } catch (e: Exception) { Timber.e(e, "Error loading closed positions from db") emptyList() @@ -451,7 +450,7 @@ class PerpetualViewModel @Inject constructor( suspend fun getTotalRealizedPnlFromDb(walletId: String): Double { return withContext(Dispatchers.IO) { try { - perpsPositionHistoryDao.getTotalRealizedPnl(walletId) ?: 0.0 + perpsPositionHistoryDao.getTotalRealizedPnl() ?: 0.0 } catch (e: Exception) { Timber.e(e, "Error loading total realized PnL from db") 0.0 @@ -460,13 +459,13 @@ class PerpetualViewModel @Inject constructor( } fun observeTotalRealizedPnl(walletId: String): Flow { - return perpsPositionHistoryDao.observeTotalRealizedPnl(walletId) + return perpsPositionHistoryDao.observeTotalRealizedPnl() } suspend fun getTotalClosedEntryValueFromDb(walletId: String): Double { return withContext(Dispatchers.IO) { try { - perpsPositionHistoryDao.getTotalClosedEntryValue(walletId) ?: 0.0 + perpsPositionHistoryDao.getTotalClosedEntryValue() ?: 0.0 } catch (e: Exception) { Timber.e(e, "Error loading total closed entry value from db") 0.0 @@ -475,7 +474,7 @@ class PerpetualViewModel @Inject constructor( } fun observeTotalClosedEntryValue(walletId: String): Flow { - return perpsPositionHistoryDao.observeTotalClosedEntryValue(walletId) + return perpsPositionHistoryDao.observeTotalClosedEntryValue() } fun getOpenPositionsPaged(walletId: String, initialLoadKey: Int? = 0): LiveData> { @@ -498,7 +497,7 @@ class PerpetualViewModel @Inject constructor( .setPageSize(Constants.PAGE_SIZE) .setEnablePlaceholders(false) .build() - return LivePagedListBuilder(perpsPositionHistoryDao.getHistoriesPaged(walletId), config) + return LivePagedListBuilder(perpsPositionHistoryDao.getHistoriesPaged(), config) .setInitialLoadKey(initialLoadKey) .build() } @@ -536,7 +535,7 @@ class PerpetualViewModel @Inject constructor( viewModelScope.launch { try { val allHistories = withContext(Dispatchers.IO) { - perpsPositionHistoryDao.getHistories(walletId, 100) + perpsPositionHistoryDao.getHistories(100) } val filteredHistories = allHistories.filter { it.marketId == marketId } onSuccess(filteredHistories) @@ -625,7 +624,7 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { - val resolvedWalletId = data.walletId ?: localBefore?.walletId + val resolvedWalletId = data.walletId.ifBlank { localBefore?.walletId ?: "" } val positionForDb = data.copy(walletId = resolvedWalletId) withContext(Dispatchers.IO) { @@ -656,7 +655,7 @@ class PerpetualViewModel @Inject constructor( viewModelScope.launch { try { val cachedHistories = withContext(Dispatchers.IO) { - perpsPositionHistoryDao.getHistories(walletId, limit, offset) + perpsPositionHistoryDao.getHistories(limit, offset) } if (cachedHistories.isNotEmpty()) { @@ -674,15 +673,12 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { Timber.d("Position history loaded: ${data.size} items") - - val histories = data.map { it.copy(walletId = walletId) } - withContext(Dispatchers.IO) { - perpsPositionHistoryDao.insertAll(histories) + perpsPositionHistoryDao.insertAll(data) } val updatedHistories = withContext(Dispatchers.IO) { - perpsPositionHistoryDao.getHistories(walletId, limit, offset) + perpsPositionHistoryDao.getHistories(limit, offset) } onSuccess(updatedHistories) } else { @@ -697,7 +693,7 @@ class PerpetualViewModel @Inject constructor( Timber.e(e, error) val cachedHistories = withContext(Dispatchers.IO) { - perpsPositionHistoryDao.getHistories(walletId, limit, offset) + perpsPositionHistoryDao.getHistories(limit, offset) } if (cachedHistories.isEmpty()) { onError(error) From fc6560340d94326c016c006abad9484e430a413a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Mar 2026 10:08:30 +0800 Subject: [PATCH 074/105] refactor: align perps bottom sheet naming and polish perps UI --- .../ui/home/web3/trade/ClosedPositionItem.kt | 8 ++++---- .../ui/home/web3/trade/TradeFragment.kt | 6 +++--- .../web3/trade/perps/AllPositionsFragment.kt | 11 +++-------- .../home/web3/trade/perps/OpenPositionItem.kt | 10 +++++----- .../home/web3/trade/perps/OpenPositionPage.kt | 4 ++-- .../home/web3/trade/perps/PerpetualContent.kt | 4 ++-- ...PerpetualGuideBottomSheetDialogFragment.kt} | 6 +++--- .../web3/trade/perps/PerpetualGuidePage.kt | 9 ++++----- .../PerpsCloseBottomSheetDialogFragment.kt | 17 +++++++++++++++-- .../PerpsConfirmBottomSheetDialogFragment.kt | 5 +++-- .../web3/trade/perps/PerpsMarketDetailPage.kt | 18 ++++++++++-------- .../res/layout/item_closed_position_list.xml | 6 +++--- app/src/main/res/values-zh-rCN/strings.xml | 8 ++++---- 13 files changed, 61 insertions(+), 51 deletions(-) rename app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/{PerpetualGuideFragment.kt => PerpetualGuideBottomSheetDialogFragment.kt} (92%) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index 1af49e07a1..b7ad612b08 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -116,13 +116,13 @@ fun ClosedPositionItem( } Text( text = sideText, - fontSize = 14.sp, + fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) Spacer(modifier = Modifier.width(6.dp)) Text( text = displaySymbol, - fontSize = 14.sp, + fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) Spacer(modifier = Modifier.width(6.dp)) @@ -140,7 +140,7 @@ fun ClosedPositionItem( Spacer(modifier = Modifier.height(4.dp)) Text( text = "${(quantity.toBigDecimalOrNull()?: BigDecimal.ZERO).abs().stripTrailingZeros().toPlainString()} ${position.tokenSymbol ?: ""}", - fontSize = 12.sp, + fontSize = 14.sp, color = MixinAppTheme.colors.textAssist ) } @@ -151,7 +151,7 @@ fun ClosedPositionItem( ) { Text( text = "${if (isProfit) "+" else "-"}$fiatSymbol${pnl.abs().multiply(fiatRate).priceFormat()}", - fontSize = 14.sp, + fontSize = 16.sp, color = pnlColor ) } 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 2d99b3580a..17091c54ee 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 @@ -71,7 +71,7 @@ import one.mixin.android.ui.common.BaseFragment import one.mixin.android.ui.home.web3.GasCheckBottomSheetDialogFragment import one.mixin.android.ui.home.web3.trade.perps.AllPerpsMarketsFragment import one.mixin.android.ui.home.web3.trade.perps.AllPositionsFragment -import one.mixin.android.ui.home.web3.trade.perps.PerpetualGuideFragment +import one.mixin.android.ui.home.web3.trade.perps.PerpetualGuideBottomSheetDialogFragment import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity import one.mixin.android.ui.home.web3.trade.perps.PerpsMarketListBottomSheetDialogFragment import one.mixin.android.ui.home.web3.trade.perps.PositionDetailFragment @@ -371,8 +371,8 @@ class TradeFragment : BaseFragment() { }, onShowTradingGuide = { this@apply.hideKeyboard() - PerpetualGuideFragment.newInstance() - .show(parentFragmentManager, PerpetualGuideFragment.TAG) + PerpetualGuideBottomSheetDialogFragment.newInstance() + .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) }, pop = { navigateUp(navController) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index 698a4c102d..09428dedc1 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -159,14 +159,9 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions positionsRv.layoutManager = LinearLayoutManager(requireContext()) emptyView.helpAction.setOnClickListener { - parentFragmentManager.beginTransaction() - .add( - android.R.id.content, - PerpetualGuideFragment.newInstance(initialTab = PerpetualGuideFragment.TAB_OVERVIEW), - PerpetualGuideFragment.TAG - ) - .addToBackStack(null) - .commit() + PerpetualGuideBottomSheetDialogFragment.newInstance( + initialTab = PerpetualGuideBottomSheetDialogFragment.TAB_OVERVIEW + ).show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt index 83d192d229..dd4ee4bc0b 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt @@ -86,13 +86,13 @@ fun OpenPositionItem( } Text( text = sideText, - fontSize = 14.sp, + fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) Spacer(modifier = Modifier.width(6.dp)) Text( text = displaySymbol, - fontSize = 14.sp, + fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.width(6.dp)) @@ -110,7 +110,7 @@ fun OpenPositionItem( Spacer(modifier = Modifier.height(4.dp)) Text( text = "$quantity ${position.tokenSymbol ?: ""}", - fontSize = 12.sp, + fontSize = 14.sp, color = MixinAppTheme.colors.textAssist ) } @@ -119,7 +119,7 @@ fun OpenPositionItem( Column(horizontalAlignment = Alignment.End) { Text( text = "${fiatSymbol}${positionValue.multiply(fiatRate).priceFormat()}", - fontSize = 14.sp, + fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(2.dp)) @@ -140,7 +140,7 @@ fun OpenPositionItem( } Text( text = "${if (unrealizedPnl >= BigDecimal.ZERO) "+" else "-"}$fiatSymbol${unrealizedPnl.abs().multiply(fiatRate).priceFormat()}", - fontSize = 12.sp, + fontSize = 14.sp, color = pnlColor ) } 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 ad7ce83edc..94b7df1654 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 @@ -481,8 +481,8 @@ fun OpenPositionPage( .size(12.dp) .clickable { val activity = context as? FragmentActivity ?: return@clickable - PerpetualGuideFragment.newInstance(PerpetualGuideFragment.TAB_POSITION) - .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) + PerpetualGuideBottomSheetDialogFragment.newInstance(PerpetualGuideBottomSheetDialogFragment.TAB_POSITION) + .show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) }, tint = MixinAppTheme.colors.textAssist ) 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 6ec780a36d..94a2806c86 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 @@ -304,7 +304,7 @@ fun PerpetualContent( ) { Text( text = stringResource(R.string.Perpetual_Markets), - fontSize = 16.sp, + fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) Icon( @@ -381,7 +381,7 @@ fun PerpetualContent( ) { Text( text = stringResource(R.string.perps_activity), - fontSize = 16.sp, + fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) Icon( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideBottomSheetDialogFragment.kt similarity index 92% rename from app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt rename to app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideBottomSheetDialogFragment.kt index d52a0f06eb..bd5e1fd2dd 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideBottomSheetDialogFragment.kt @@ -18,10 +18,10 @@ import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment import one.mixin.android.util.SystemUIManager @AndroidEntryPoint -class PerpetualGuideFragment : MixinComposeBottomSheetDialogFragment() { +class PerpetualGuideBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { companion object { - const val TAG = "PerpetualGuideFragment" + const val TAG = "PerpetualGuideBottomSheetDialogFragment" private const val ARGS_INITIAL_TAB = "args_initial_tab" const val TAB_OVERVIEW = 0 @@ -30,7 +30,7 @@ class PerpetualGuideFragment : MixinComposeBottomSheetDialogFragment() { const val TAB_LEVERAGE = 3 const val TAB_POSITION = 4 - fun newInstance(initialTab: Int = TAB_OVERVIEW) = PerpetualGuideFragment().apply { + fun newInstance(initialTab: Int = TAB_OVERVIEW) = PerpetualGuideBottomSheetDialogFragment().apply { arguments = Bundle().apply { putInt(ARGS_INITIAL_TAB, initialTab) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index d4c02ead61..c39e1b88cf 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -80,7 +80,7 @@ data class AdjusterConfig( @Composable fun PerpetualGuidePage( - initialTab: Int = PerpetualGuideFragment.TAB_OVERVIEW, + initialTab: Int = PerpetualGuideBottomSheetDialogFragment.TAB_OVERVIEW, pop: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -923,16 +923,15 @@ private fun buildOrderValueText( orderValueUsdt: Int, localSolPrice: BigDecimal?, ): String { - val usdtText = "${formatGuideInt(orderValueUsdt)} USDT" - val solPrice = localSolPrice ?: return "$usdtText (-- SOL)" + val solPrice = localSolPrice ?: return "-- SOL" if (solPrice <= BigDecimal.ZERO) { - return "$usdtText (-- SOL)" + return "-- SOL" } val solAmount = BigDecimal(orderValueUsdt.toString()) .divide(solPrice, 2, RoundingMode.HALF_UP) .stripTrailingZeros() .toPlainString() - return "$usdtText ($solAmount SOL)" + return "$solAmount SOL" } private fun formatPercent(percent: Float): String { 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 600f95b029..28d1723e83 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 @@ -65,6 +65,7 @@ import one.mixin.android.extension.screenHeight import one.mixin.android.extension.withArgs import one.mixin.android.ui.common.BottomSheetViewModel import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.ui.common.VerifyBottomSheetDialogFragment import one.mixin.android.ui.home.web3.components.ActionBottom import one.mixin.android.ui.tip.wc.compose.ItemWalletContent import one.mixin.android.ui.wallet.ItemUserContent @@ -502,7 +503,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(id = R.string.Retry), cancelAction = { dismiss() }, - confirmAction = { closePosition() }, + confirmAction = { showVerifyPinThenClose() }, ) } @@ -512,7 +513,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(id = R.string.Confirm), cancelAction = { dismiss() }, - confirmAction = { closePosition() }, + confirmAction = { showVerifyPinThenClose() }, ) } @@ -539,7 +540,19 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen super.onDismiss(dialog) } + private fun showVerifyPinThenClose() { + VerifyBottomSheetDialogFragment.newInstance( + title = getString(R.string.Verify_PIN), + disableBiometric = true, + ).apply { + disableToast = true + }.setOnPinSuccess { + closePosition() + }.showNow(parentFragmentManager, VerifyBottomSheetDialogFragment.TAG) + } + private fun closePosition() { + errorInfo = null step = Step.Sending viewModel.closePerpsOrder( positionId = positionId, 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 ca4b189f85..c332ee6324 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 @@ -496,8 +496,9 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm modifier = Modifier .size(12.dp) .clickable { - PerpetualGuideFragment.newInstance(PerpetualGuideFragment.TAB_POSITION) - .show(parentFragmentManager, PerpetualGuideFragment.TAG) + PerpetualGuideBottomSheetDialogFragment.newInstance( + PerpetualGuideBottomSheetDialogFragment.TAB_POSITION + ).show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) }, tint = MixinAppTheme.colors.textAssist ) 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 7b7855f790..a585903566 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 @@ -204,8 +204,9 @@ fun PerpsMarketDetailPage( market = market!!, onLearnClick = { val activity = context as? FragmentActivity ?: return@MarketInfoCard - PerpetualGuideFragment.newInstance(PerpetualGuideFragment.TAB_OVERVIEW) - .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) + PerpetualGuideBottomSheetDialogFragment.newInstance( + PerpetualGuideBottomSheetDialogFragment.TAB_OVERVIEW + ).show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) } ) } @@ -724,8 +725,9 @@ private fun OpenPositionCard( .size(12.dp) .clickable { val activity = context as? FragmentActivity ?: return@clickable - PerpetualGuideFragment.newInstance(PerpetualGuideFragment.TAB_POSITION) - .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) + PerpetualGuideBottomSheetDialogFragment.newInstance( + PerpetualGuideBottomSheetDialogFragment.TAB_POSITION + ).show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) }, tint = MixinAppTheme.colors.textAssist ) @@ -753,8 +755,8 @@ private fun OpenPositionCard( // .size(12.dp) // .clickable { // val activity = context as? FragmentActivity ?: return@clickable -// PerpetualGuideFragment.newInstance() -// .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) +// PerpetualGuideBottomSheetDialogFragment.newInstance() +// .show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) // }, // tint = MixinAppTheme.colors.textAssist // ) @@ -802,8 +804,8 @@ private fun OpenPositionCard( // .size(12.dp) // .clickable { // val activity = context as? FragmentActivity ?: return@clickable -// PerpetualGuideFragment.newInstance() -// .show(activity.supportFragmentManager, PerpetualGuideFragment.TAG) +// PerpetualGuideBottomSheetDialogFragment.newInstance() +// .show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) // }, // tint = MixinAppTheme.colors.textAssist // ) diff --git a/app/src/main/res/layout/item_closed_position_list.xml b/app/src/main/res/layout/item_closed_position_list.xml index ef6415836d..078b399fb0 100644 --- a/app/src/main/res/layout/item_closed_position_list.xml +++ b/app/src/main/res/layout/item_closed_position_list.xml @@ -44,7 +44,7 @@ android:ellipsize="end" android:maxLines="1" android:textColor="?attr/text_primary" - android:textSize="14sp" + android:textSize="16sp" tools:text="Long BTC" /> @@ -89,7 +89,7 @@ android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textColor="?attr/text_primary" - android:textSize="14sp" + android:textSize="16sp" tools:text="$100.00" /> 错误 10614: 输入金额太小或太大,请重新输入。 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 - 错误 10650: 仓位规模太小,请调整后重试。 + 错误 10650: 规模太小,请调整后重试。 错误 10651:当前市场已开仓。 当前市场已开仓。 错误 20114:验证码已过期 @@ -2247,7 +2247,7 @@ 风险提示 杠杆交易可能放大收益,同时也会放大亏损。 当保证金不足时,仓位可能被强制平仓。 - 请合理控制杠杆倍数与仓位规模,谨慎交易。 + 请合理控制杠杆倍数与规模,谨慎交易。 举例说明 开仓交易 价格上涨 %1$s%% → 盈利 %2$s%% (+%3$s) @@ -2274,7 +2274,7 @@ 杠杆会同时放大收益和亏损。 杠杆倍数越高,盈亏随价格波动的变化越大。 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 - 当前合约的仓位规模由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 + 当前合约的规模由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 用途 决定本次交易的市场敞口规模。 影响盈亏变化的放大倍数。 @@ -2287,7 +2287,7 @@ 杠杆倍数 选择代币 选择杠杆 - 仓位规模 + 规模 清算价格 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 From 0302d1f7e081051ba5a11ba2af7c4f438727dc66 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Mar 2026 13:26:44 +0800 Subject: [PATCH 075/105] Revert market page --- .../mixin/android/ui/wallet/MarketDetailsFragment.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt index c22a8fcd29..71f9b2b40f 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt @@ -28,7 +28,7 @@ import one.mixin.android.extension.navigate import one.mixin.android.extension.numberFormat2 import one.mixin.android.extension.numberFormat8 import one.mixin.android.extension.numberFormatCompact -import one.mixin.android.extension.priceFormat +import one.mixin.android.extension.priceFormat2 import one.mixin.android.extension.setQuoteText import one.mixin.android.extension.setQuoteTextWithBackgroud import one.mixin.android.extension.toast @@ -308,7 +308,7 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) { titleView.rightExtraIb.isVisible = true assetSymbol.text = info.symbol assetName.text = info.name - assetRank.text = getString(R.string.Market_Cap_Rank, info.marketCapRank) + assetRank.text = "#${info.marketCapRank}" currentPrice = priceFormat(info.currentPrice) priceValue.text = currentPrice marketHigh.text = priceFormat(info.high24h) @@ -417,16 +417,16 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) { if (price == BigDecimal.ZERO) { "≈ ${Fiats.getSymbol()}0.00" } else { - "≈ ${Fiats.getSymbol()}${price.priceFormat()}" + "≈ ${Fiats.getSymbol()}${price.numberFormat2()}" } } catch (_: NumberFormatException) { - "≈ ${Fiats.getSymbol()}${price.priceFormat()}" + "≈ ${Fiats.getSymbol()}${price.numberFormat2()}" } priceRise.visibility = VISIBLE currentRise = "${(BigDecimal(marketItem.priceChangePercentage24H)).numberFormat2()}%" if (balances != BigDecimal.ZERO && marketItem.priceChangePercentage24H.isNotEmpty()) { val change = changeUsd.multiply(balances).multiply(BigDecimal(Fiats.getRate())) - balanceChange.setQuoteText("${if (change >= BigDecimal.ZERO) "+" else "-"}${Fiats.getSymbol()}${change.abs().priceFormat()} ($currentRise)", isPositive) + balanceChange.setQuoteText("${if (change >= BigDecimal.ZERO) "+" else "-"}${Fiats.getSymbol()}${change.priceFormat2().replace("-", "")} ($currentRise)", isPositive) riseTitle.isVisible = true } else { balanceChange.setTextColor(requireContext().colorAttr(R.attr.text_assist)) From a4184e47efdfc5aec53ee77dd4bc5099e9a80f5a Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Mar 2026 14:17:16 +0800 Subject: [PATCH 076/105] Update refresh logic --- .../web3/trade/perps/AllPositionsFragment.kt | 32 ++++++++++++++++--- .../home/web3/trade/perps/PerpetualContent.kt | 11 ++++++- .../web3/trade/perps/PerpsMarketDetailPage.kt | 11 +++++++ .../trade/perps/PositionDetailFragment.kt | 8 ++++- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index 09428dedc1..f22fd6aa73 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -44,7 +44,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions private const val ARGS_POSITION_TYPE = "args_position_type" private const val TYPE_OPEN = "type_open" private const val TYPE_CLOSED = "type_closed" - private const val POSITION_REFRESH_INTERVAL_MS = 10_000L + private const val POSITION_REFRESH_INTERVAL_MS = 3_000L private const val CLOSED_POSITION_REFRESH_LIMIT = 100 fun newInstance(showOpenPositions: Boolean = false) = AllPositionsFragment().apply { @@ -111,6 +111,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions private var openPositionsLiveData: LiveData>? = null private var closedPositionsLiveData: LiveData>? = null private var totalValueJob: Job? = null + private var previousOpenPositionsCount: Int? = null private var lastOpenTotalValue: Double = 0.0 private var lastOpenTotalPnl: Double = 0.0 @@ -166,6 +167,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions } loadPositions() + observeOpenPositionCountChanges() observePeriodicRefresh() } @@ -178,6 +180,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions lastOpenTotalPnl = 0.0 lastClosedTotalPnl = 0.0 lastClosedTotalEntryValue = 0.0 + previousOpenPositionsCount = null if (positionType == PositionType.OPEN) { binding.titleView.setSubTitle(getString(R.string.perps_positions), "") @@ -275,18 +278,37 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { val walletId = Session.getAccountId() ?: return@repeatOnLifecycle + viewModel.refreshPositionHistory( + walletId = walletId, + limit = CLOSED_POSITION_REFRESH_LIMIT + ) while (isActive) { viewModel.refreshPositions(walletId) - viewModel.refreshPositionHistory( - walletId = walletId, - limit = CLOSED_POSITION_REFRESH_LIMIT - ) delay(POSITION_REFRESH_INTERVAL_MS) } } } } + private fun observeOpenPositionCountChanges() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + val walletId = Session.getAccountId() ?: return@repeatOnLifecycle + viewModel.observeOpenPositions(walletId).collect { positions -> + val currentCount = positions.size + val lastCount = previousOpenPositionsCount + if (lastCount != null && currentCount < lastCount) { + viewModel.refreshPositionHistory( + walletId = walletId, + limit = CLOSED_POSITION_REFRESH_LIMIT + ) + } + previousOpenPositionsCount = currentCount + } + } + } + } + private fun calculatePercent(value: Double, base: Double): Double { if (base == 0.0) { return 0.0 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 94a2806c86..2ad8855049 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 @@ -100,6 +100,7 @@ fun PerpetualContent( val closedPositions by remember(walletId) { viewModel.observeClosedPositions(walletId, CLOSED_POSITION_PREVIEW_LIMIT) }.collectAsStateWithLifecycle(initialValue = emptyList()) + var previousOpenPositionsCount by remember(walletId) { mutableStateOf(null) } val openPositionsCount = openPositions.size val openPositionsPreview = openPositions.take(3) val marketsPreview = markets.take(3) @@ -140,14 +141,22 @@ fun PerpetualContent( ) } ) + viewModel.refreshPositionHistory(walletId, limit = CLOSED_POSITION_PREVIEW_LIMIT) while (isActive) { viewModel.refreshPositions(walletId) - viewModel.refreshPositionHistory(walletId, limit = CLOSED_POSITION_PREVIEW_LIMIT) delay(POSITION_REFRESH_INTERVAL_MS) } } } + LaunchedEffect(walletId, openPositionsCount) { + val lastCount = previousOpenPositionsCount + if (lastCount != null && openPositionsCount < lastCount) { + viewModel.refreshPositionHistory(walletId, limit = CLOSED_POSITION_PREVIEW_LIMIT) + } + previousOpenPositionsCount = openPositionsCount + } + Column(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier 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 a585903566..8018544300 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 @@ -98,6 +98,7 @@ fun PerpsMarketDetailPage( flowOf(emptyList()) } }.collectAsStateWithLifecycle(initialValue = emptyList()) + var previousOpenPositionsCount by remember(walletId) { mutableStateOf(null) } val currentPosition = openPositions.firstOrNull { it.marketId == marketId } val closedPositions = allClosedPositions.filter { it.marketId == marketId } val timeFrameValues = listOf("1m", "5m", "15m", "1h", "4h", "1d", "1w") @@ -128,6 +129,16 @@ fun PerpsMarketDetailPage( ) } + LaunchedEffect(walletId, openPositions.size) { + if (walletId.isEmpty()) return@LaunchedEffect + val lastCount = previousOpenPositionsCount + val currentCount = openPositions.size + if (lastCount != null && currentCount < lastCount) { + viewModel.refreshPositionHistory(walletId, limit = CLOSED_POSITION_PREVIEW_LIMIT) + } + previousOpenPositionsCount = currentCount + } + PageScaffold( title = displaySymbol, subtitleText = stringResource(R.string.Perpetual), diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt index e05e51ccc5..02011a6c87 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -140,7 +140,13 @@ class PositionDetailFragment : BaseFragment() { val perpsPosition = position.toPosition() PerpsCloseBottomSheetDialogFragment.newInstance(perpsPosition) .setOnDone { - activity?.onBackPressedDispatcher?.onBackPressed() + PerpsActivity.showDetail( + requireContext(), + position.marketId, + position.displaySymbol.orEmpty(), + position.displaySymbol.orEmpty(), + position.tokenSymbol.orEmpty() + ) } .showNow(parentFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) } From dbb3c929e0d457981c77ee3c744b3d5bf65a8573 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Mar 2026 14:25:20 +0800 Subject: [PATCH 077/105] Fix router error handle --- .../ui/home/web3/trade/SwapViewModel.kt | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapViewModel.kt index c328cc057b..bbc3f8af3f 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapViewModel.kt @@ -254,21 +254,30 @@ class SwapViewModel } suspend fun refreshPendingOrders(): Boolean = withContext(Dispatchers.IO) { - val orderDao = walletDatabase.orderDao() - val pending = orderDao.getPendingOrders() - if (pending.isEmpty()) return@withContext false - val ids = pending.map { it.orderId } - val resp = assetRepository.getLimitOrders(ids) - if (resp.isSuccess && resp.data != null) { - orderDao.insertListSuspend(resp.data!!) + try { + val orderDao = walletDatabase.orderDao() + val pending = orderDao.getPendingOrders() + if (pending.isEmpty()) return@withContext false + val ids = pending.map { it.orderId } + val resp = assetRepository.getLimitOrders(ids) + if (resp.isSuccess && resp.data != null) { + orderDao.insertListSuspend(resp.data!!) + } + return@withContext true + } catch (t: Throwable) { + Timber.w(t, "refreshPendingOrders failed") } - return@withContext true + return@withContext false } suspend fun refreshOrders(walletId: String) = withContext(Dispatchers.IO) { - val offsetKey = "order_offset_$walletId" - val offset = Web3PropertyHelper.findValueByKey(offsetKey, "") - refreshOrdersInternal(walletId, offset.ifEmpty { null }, offsetKey) + try { + val offsetKey = "order_offset_$walletId" + val offset = Web3PropertyHelper.findValueByKey(offsetKey, "") + refreshOrdersInternal(walletId, offset.ifEmpty { null }, offsetKey) + } catch (t: Throwable) { + Timber.w(t, "refreshOrders failed for walletId=%s", walletId) + } } private suspend fun refreshOrdersInternal(walletId: String, offset: String?, offsetKey: String, previousLastCreatedAt: String? = null) { From 1815518a52697267b11575a9671e928e3fe6ac10 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Mar 2026 14:40:13 +0800 Subject: [PATCH 078/105] Open perps market --- .../home/web3/trade/perps/OpenPositionPage.kt | 7 ++- .../ui/home/web3/trade/perps/PerpsActivity.kt | 60 +++++++++++++------ 2 files changed, 47 insertions(+), 20 deletions(-) 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 94b7df1654..8c7bed51f1 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 @@ -84,6 +84,7 @@ fun OpenPositionPage( market: PerpsMarket, isLong: Boolean, onBack: () -> Unit, + onOpenSuccess: (String) -> Unit = { onBack() }, selectedToken: TokenItem?, onTokenSelect: () -> Unit = {}, onCurrentTokenChange: (TokenItem?) -> Unit = {}, @@ -584,9 +585,9 @@ fun OpenPositionPage( tokenSymbol = token.symbol, payUrl = response.paymentUrl ).setOnDone { - onBack() - }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) - }, + onOpenSuccess(m.marketId) + }.show(activity.supportFragmentManager, PerpsConfirmBottomSheetDialogFragment.TAG) + }, onError = { errorCode, errorMessage -> errorInfo = if (errorCode > 0) { context.getMixinErrorStringByCode(errorCode, errorMessage) 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 5ac14cea58..63b8089a80 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 @@ -12,6 +12,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -38,6 +39,7 @@ class PerpsActivity : BaseActivity() { lateinit var perpsMarketDao: PerpsMarketDao private var selectedToken by mutableStateOf(null) + private var renderJob: Job? = null companion object { private const val EXTRA_MARKET_ID = "extra_market_id" @@ -59,6 +61,7 @@ class PerpsActivity : BaseActivity() { marketTokenSymbol: String = "", ) { val intent = Intent(context, PerpsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) putExtra(EXTRA_MARKET_ID, marketId) putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) putExtra(EXTRA_MARKET_DISPLAY_SYMBOL, marketDisplaySymbol) @@ -77,6 +80,7 @@ class PerpsActivity : BaseActivity() { isLong: Boolean, ) { val intent = Intent(context, PerpsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) putExtra(EXTRA_MARKET_ID, marketId) putExtra(EXTRA_MARKET_SYMBOL, marketSymbol) putExtra(EXTRA_MARKET_DISPLAY_SYMBOL, marketDisplaySymbol) @@ -90,18 +94,28 @@ class PerpsActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + observePositionRefresh() + renderPage() + } - val marketId = intent.getStringExtra(EXTRA_MARKET_ID) ?: "" - val marketSymbol = intent.getStringExtra(EXTRA_MARKET_SYMBOL) ?: "" - val displaySymbol = intent.getStringExtra(EXTRA_MARKET_DISPLAY_SYMBOL) ?: "" - val tokenSymbol = intent.getStringExtra(EXTRA_MARKET_TOKEN_SYMBOL) ?: "" - val mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_DETAIL - val isLong = intent.getBooleanExtra(EXTRA_IS_LONG, true) + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + renderPage() + } - observePositionRefresh() + private fun renderPage() { + renderJob?.cancel() + val currentIntent = intent + val marketId = currentIntent.getStringExtra(EXTRA_MARKET_ID) ?: "" + val marketSymbolExtra = currentIntent.getStringExtra(EXTRA_MARKET_SYMBOL).orEmpty() + val displaySymbolExtra = currentIntent.getStringExtra(EXTRA_MARKET_DISPLAY_SYMBOL).orEmpty() + val tokenSymbolExtra = currentIntent.getStringExtra(EXTRA_MARKET_TOKEN_SYMBOL).orEmpty() + val mode = currentIntent.getStringExtra(EXTRA_MODE) ?: MODE_DETAIL + val isLong = currentIntent.getBooleanExtra(EXTRA_IS_LONG, true) if (mode == MODE_OPEN_POSITION) { - lifecycleScope.launch { + renderJob = lifecycleScope.launch { val market = withContext(Dispatchers.IO) { perpsMarketDao.getMarket(marketId) } @@ -116,6 +130,9 @@ class PerpsActivity : BaseActivity() { market = market, isLong = isLong, onBack = { finish() }, + onOpenSuccess = { openedMarketId -> + showDetail(this@PerpsActivity, openedMarketId, "", "", "") + }, selectedToken = selectedToken, onTokenSelect = { showTokenSelection() }, onCurrentTokenChange = { token -> selectedToken = token } @@ -126,15 +143,24 @@ class PerpsActivity : BaseActivity() { return } - setContent { - MixinAppTheme { - PerpsMarketDetailPage( - marketId = marketId, - marketSymbol = marketSymbol, - displaySymbol = displaySymbol, - tokenSymbol = tokenSymbol, - onBack = { finish() } - ) + renderJob = lifecycleScope.launch { + val market = withContext(Dispatchers.IO) { + perpsMarketDao.getMarket(marketId) + } + val displaySymbol = displaySymbolExtra.ifBlank { market?.displaySymbol.orEmpty() } + val marketSymbol = marketSymbolExtra.ifBlank { displaySymbol } + val tokenSymbol = tokenSymbolExtra.ifBlank { market?.tokenSymbol.orEmpty() } + + setContent { + MixinAppTheme { + PerpsMarketDetailPage( + marketId = marketId, + marketSymbol = marketSymbol, + displaySymbol = displaySymbol, + tokenSymbol = tokenSymbol, + onBack = { finish() } + ) + } } } } From 0b5627483a7a912449db719c06f113ea546c4c4c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Mar 2026 15:06:02 +0800 Subject: [PATCH 079/105] refactor: switch perps margin limits to min_amount and max_amount --- .../one.mixin.android.db.PerpsDatabase/1.json | 28 +++++----------- .../android/api/response/perps/PerpsMarket.kt | 20 ++++------- .../web3/trade/TotalPositionValueAdapter.kt | 19 ++++++++--- .../web3/trade/perps/AllPositionsFragment.kt | 2 +- .../home/web3/trade/perps/OpenPositionPage.kt | 33 +++++++++++++++++-- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 7 files changed, 64 insertions(+), 42 deletions(-) diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index d34fa61ff4..d5e0339d89 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "05fceb901a48fd5e06c755017d38bf94", + "identityHash": "523f621632813387fa5c95335f1ef136", "entities": [ { "tableName": "positions", @@ -210,7 +210,7 @@ }, { "tableName": "markets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `min_order_size` TEXT NOT NULL, `max_order_size` TEXT NOT NULL, `min_order_value` TEXT NOT NULL, `max_order_value` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `amount` TEXT NOT NULL, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `min_amount` TEXT NOT NULL, `max_amount` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `amount` TEXT NOT NULL, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", "fields": [ { "fieldPath": "marketId", @@ -261,26 +261,14 @@ "notNull": true }, { - "fieldPath": "minOrderSize", - "columnName": "min_order_size", + "fieldPath": "minAmount", + "columnName": "min_amount", "affinity": "TEXT", "notNull": true }, { - "fieldPath": "maxOrderSize", - "columnName": "max_order_size", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "minOrderValue", - "columnName": "min_order_value", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "maxOrderValue", - "columnName": "max_order_value", + "fieldPath": "maxAmount", + "columnName": "max_amount", "affinity": "TEXT", "notNull": true }, @@ -361,7 +349,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '05fceb901a48fd5e06c755017d38bf94')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '523f621632813387fa5c95335f1ef136')" ] } -} +} \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index 803764683e..9db7c59074 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -39,21 +39,13 @@ data class PerpsMarket( @ColumnInfo(name = "funding_rate") val fundingRate: String, - @SerializedName("min_order_size") - @ColumnInfo(name = "min_order_size") - val minOrderSize: String, + @SerializedName("min_amount") + @ColumnInfo(name = "min_amount") + val minAmount: String, - @SerializedName("max_order_size") - @ColumnInfo(name = "max_order_size") - val maxOrderSize: String, - - @SerializedName("min_order_value") - @ColumnInfo(name = "min_order_value") - val minOrderValue: String, - - @SerializedName("max_order_value") - @ColumnInfo(name = "max_order_value") - val maxOrderValue: String, + @SerializedName("max_amount") + @ColumnInfo(name = "max_amount") + val maxAmount: String, @SerializedName("last") @ColumnInfo(name = "last") diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index 1ba85a7303..d5a009bc91 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -14,7 +14,9 @@ import one.mixin.android.extension.priceFormat import one.mixin.android.vo.Fiats import java.math.BigDecimal -class TotalPositionValueAdapter : RecyclerView.Adapter() { +class TotalPositionValueAdapter( + private val isQuoteColorReversed: Boolean = false, +) : RecyclerView.Adapter() { private var totalValue: BigDecimal = BigDecimal.ZERO private var subValue: BigDecimal = BigDecimal.ZERO private var subPercent: BigDecimal? = null @@ -42,7 +44,7 @@ class TotalPositionValueAdapter : RecyclerView.Adapter最低金额 %1$s %2$s 最高金额 %1$s %2$s 金额范围 %1$s %2$s ~ %3$s %4$s + 最小保证金为 %1$s + 最大保证金为 %1$s 授权 借款 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32f6e2b84b..778ce496f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1664,6 +1664,8 @@ Minimum amount %1$s %2$s Maximum amount %1$s %2$s Amount range be %1$s %2$s ~ %3$s %4$s + The minimun margin is %1$s + The maximum margin is %1$s Approve Borrow From 6a0b6456286238452f0825a2598a801596f303e5 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 13 Mar 2026 23:00:21 +0800 Subject: [PATCH 080/105] fix: leverage option on open position page --- .../ui/home/web3/trade/InputTextField.kt | 17 +++++++----- .../LeverageBottomSheetDialogFragment.kt | 27 +++++++++++++------ .../home/web3/trade/perps/OpenPositionPage.kt | 14 +++++----- .../PerpsConfirmBottomSheetDialogFragment.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 6 files changed, 38 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt index 30188c066b..f9ea35f7f4 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 @@ -42,6 +42,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.request.ImageRequest @@ -64,6 +65,8 @@ fun InputContent( readOnly: Boolean = false, inlineEndCompose: (@Composable () -> Unit)? = null, tokenIconSize: Dp = 32.dp, + inputFontSize: TextUnit = 24.sp, + inputFontWeight: FontWeight = FontWeight.Black, ) { if (readOnly) { Column(modifier = Modifier.fillMaxWidth()) { @@ -79,8 +82,8 @@ fun InputContent( AutoSizeText( text = text, color = if (text == "0") MixinAppTheme.colors.textRemarks else MixinAppTheme.colors.textPrimary, - fontSize = 24.sp, - fontWeight = FontWeight.Black, + fontSize = inputFontSize, + fontWeight = inputFontWeight, textAlign = TextAlign.Start, ) } @@ -146,15 +149,15 @@ fun InputContent( .alpha(if (inlineEndCompose != null) 0f else 1f), textStyle = TextStyle( fontSize = when { - textFieldValue.text.length <= 15 -> 24.sp + textFieldValue.text.length <= 15 -> inputFontSize else -> { val excess = textFieldValue.text.length - 15 val reduction = excess * 2 - (24 - reduction).coerceAtLeast(16).sp + (inputFontSize.value - reduction).coerceAtLeast(16f).sp } }, color = MixinAppTheme.colors.textPrimary, - fontWeight = FontWeight.Black, + fontWeight = inputFontWeight, textAlign = TextAlign.Start, ), cursorBrush = SolidColor(MixinAppTheme.colors.textPrimary), @@ -166,8 +169,8 @@ fun InputContent( AutoSizeText( text = "0", color = MixinAppTheme.colors.textRemarks, - fontSize = 24.sp, - fontWeight = FontWeight.Black, + fontSize = inputFontSize, + fontWeight = inputFontWeight, modifier = Modifier.align(Alignment.CenterStart) ) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt index ac7acbf4d8..c2aa90f498 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt @@ -144,7 +144,10 @@ private fun LeverageContent( onCancel: () -> Unit, onApply: (Float) -> Unit ) { - var tempLeverage by remember { mutableFloatStateOf(currentLeverage) } + val boundedMaxLeverage = maxLeverage.coerceAtLeast(1) + var tempLeverage by remember(currentLeverage, boundedMaxLeverage) { + mutableFloatStateOf(currentLeverage.coerceIn(1f, boundedMaxLeverage.toFloat())) + } Column( modifier = Modifier @@ -181,8 +184,8 @@ private fun LeverageContent( Slider( value = tempLeverage, onValueChange = { tempLeverage = it }, - valueRange = 1f..maxLeverage.toFloat(), - steps = maxLeverage - 2, + valueRange = 1f..boundedMaxLeverage.toFloat(), + steps = (boundedMaxLeverage - 2).coerceAtLeast(0), colors = SliderDefaults.colors( thumbColor = MixinAppTheme.colors.accent, activeTrackColor = MixinAppTheme.colors.accent, @@ -199,10 +202,18 @@ private fun LeverageContent( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - val steps = 5 - val stepValue = maxLeverage / (steps - 1) - for (i in 0 until steps) { - val value = if (i == steps - 1) maxLeverage else (i * stepValue) + val labels = if (boundedMaxLeverage == 1) { + listOf(1) + } else { + List(5) { index -> + when (index) { + 0 -> 1 + 4 -> boundedMaxLeverage + else -> 1 + ((boundedMaxLeverage - 1) * index / 4) + } + }.distinct() + } + labels.forEach { value -> Text( text = "${value}x", fontSize = 12.sp, @@ -346,4 +357,4 @@ private fun ProfitLossInfo( color = MixinAppTheme.colors.textAssist ) } -} \ No newline at end of file +} 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 cf545f22f6..993ab88bb1 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 @@ -109,7 +109,7 @@ fun OpenPositionPage( val savedLeverage = remember(marketId) { context.defaultSharedPreferences - .getInt(getLeveragePrefKey(marketId), 10) + .getInt(getLeveragePrefKey(marketId), 1) .coerceAtLeast(1) } var leverage by remember(marketId) { mutableFloatStateOf(savedLeverage.toFloat()) } @@ -279,7 +279,9 @@ fun OpenPositionPage( onTokenSelect() }, onInputChanged = { usdtAmount = it }, - tokenIconSize = 25.dp + tokenIconSize = 25.dp, + inputFontSize = 28.sp, + inputFontWeight = FontWeight.W500, ) Row( @@ -385,8 +387,8 @@ fun OpenPositionPage( }.show(activity.supportFragmentManager, LeverageBottomSheetDialogFragment.TAG) }, text = "${leverage.toInt()}x", - fontSize = 16.sp, - fontWeight = FontWeight.Bold, + fontSize = 28.sp, + fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) @@ -409,7 +411,7 @@ fun OpenPositionPage( val displayText = when (lev) { -1 -> stringResource(R.string.slippage_custom) - maxLeverage -> stringResource(R.string.Max) + maxLeverage.takeIf { it > 1 } -> stringResource(R.string.Max) else -> "${lev}x" } @@ -668,7 +670,7 @@ fun OpenPositionPage( private fun generateLeverageOptions(maxLeverage: Int): List { val safeMaxLeverage = maxLeverage.coerceAtLeast(1) - val baseOptions = listOf(1, 2, 5, 10, 20) + val baseOptions = listOf(2, 5, 10, 20) val options = baseOptions .filter { it in 1 until safeMaxLeverage } .toMutableList() 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 c332ee6324..d0abc221a6 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 @@ -361,7 +361,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Box(modifier = Modifier.height(20.dp)) PerpsInfoItem( - title = stringResource(R.string.Margin).uppercase() + " (${stringResource(R.string.Isolated)})", + title = stringResource(R.string.Margin).uppercase(), value = "$amount $tokenSymbol" ) Box(modifier = Modifier.height(20.dp)) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8cf048694a..90f33fdfc5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2308,7 +2308,6 @@ 时间信息 开仓时间 平仓时间 - 逐仓 Mixin Futures 确认开仓 开仓成功 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 778ce496f3..7e44eefe54 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2368,7 +2368,6 @@ Time Info Open Time Close Time - Isolated Mixin Futures Wallet not found How perps works? From 3900c656f98aa4c22730455a61967eee8cead5bb Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Sat, 14 Mar 2026 09:49:30 +0800 Subject: [PATCH 081/105] Update titles --- .../mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt | 2 +- .../web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 993ab88bb1..37cbcc397f 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 @@ -255,7 +255,7 @@ fun OpenPositionPage( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.Margin), + text = stringResource(R.string.Amount), fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary ) 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 d0abc221a6..9630620798 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 @@ -361,7 +361,7 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Box(modifier = Modifier.height(20.dp)) PerpsInfoItem( - title = stringResource(R.string.Margin).uppercase(), + title = stringResource(R.string.Amount).uppercase(), value = "$amount $tokenSymbol" ) Box(modifier = Modifier.height(20.dp)) From c5a2ad0a51956dc099f6a93c830c27522cb32089 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 16 Mar 2026 10:03:56 +0800 Subject: [PATCH 082/105] fix: route perps market clicks to detail page when position exists --- ...erpsMarketListBottomSheetDialogFragment.kt | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt index 2edb36f6f2..3945fadf88 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt @@ -16,10 +16,12 @@ import one.mixin.android.Constants import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.databinding.FragmentMarketListBottomSheetBinding import one.mixin.android.db.perps.PerpsMarketDao +import one.mixin.android.db.perps.PerpsPositionDao import one.mixin.android.extension.appCompatActionBarHeight import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.getSafeAreaInsetsTop import one.mixin.android.extension.withArgs +import one.mixin.android.session.Session import one.mixin.android.ui.common.MixinBottomSheetDialogFragment import one.mixin.android.util.viewBinding import one.mixin.android.widget.BottomSheet @@ -48,6 +50,8 @@ class PerpsMarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment( @Inject lateinit var perpsMarketDao: PerpsMarketDao + @Inject + lateinit var perpsPositionDao: PerpsPositionDao private val isLong by lazy { requireArguments().getBoolean(ARGS_IS_LONG, true) } private var allMarkets = listOf() @@ -115,14 +119,35 @@ class PerpsMarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment( } private fun onMarketClick(market: PerpsMarket) { - PerpsActivity.showOpenPosition( - context = requireContext(), - marketId = market.marketId, - marketSymbol = market.displaySymbol, - marketDisplaySymbol = market.displaySymbol, - marketTokenSymbol = market.tokenSymbol, - isLong = isLong - ) - dismiss() + lifecycleScope.launch { + val walletId = Session.getAccountId() + val hasOpenPosition = if (walletId.isNullOrEmpty()) { + false + } else { + withContext(Dispatchers.IO) { + perpsPositionDao.getOpenPositions(walletId).any { it.marketId == market.marketId } + } + } + + if (hasOpenPosition) { + PerpsActivity.showDetail( + context = requireContext(), + marketId = market.marketId, + marketSymbol = market.displaySymbol, + marketDisplaySymbol = market.displaySymbol, + marketTokenSymbol = market.tokenSymbol + ) + } else { + PerpsActivity.showOpenPosition( + context = requireContext(), + marketId = market.marketId, + marketSymbol = market.displaySymbol, + marketDisplaySymbol = market.displaySymbol, + marketTokenSymbol = market.tokenSymbol, + isLong = isLong + ) + } + dismiss() + } } } From 8d322232ffeb6a35b2daacf352991a026b60235b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 16 Mar 2026 10:09:53 +0800 Subject: [PATCH 083/105] feat: trade page title uses to-token symbol for swap tabs --- .../ui/home/web3/trade/TradeFragment.kt | 46 +++++++++++++++++++ .../android/ui/home/web3/trade/TradePage.kt | 21 ++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) 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 17091c54ee..b9e90679e0 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 @@ -147,6 +147,8 @@ class TradeFragment : BaseFragment() { private var toToken: SwapToken? by mutableStateOf(null) private var limitFromToken: SwapToken? by mutableStateOf(null) private var limitToToken: SwapToken? by mutableStateOf(null) + private var initialSwapToSymbol: String? by mutableStateOf(null) + private var initialLimitToSymbol: String? by mutableStateOf(null) private var initialAmount: String? = null private var lastOrderTime: Long by mutableLongStateOf(0) @@ -186,6 +188,7 @@ class TradeFragment : BaseFragment() { ): View { initAmount() lifecycleScope.launch { + preloadTradeTitleSymbols() val chainIds = walletId?.let { swapViewModel.getAddresses(it).map { it.chainId @@ -281,6 +284,8 @@ class TradeFragment : BaseFragment() { swapTo = toToken, limitFrom = limitFromToken, limitTo = limitToToken, + initialSwapToSymbol = initialSwapToSymbol, + initialLimitToSymbol = initialLimitToSymbol, inMixin = inMixin(), orderBadge = orderBadge, isLimitOrderTabBadgeDismissed = isLimitOrderTabBadgeDismissed, @@ -777,6 +782,45 @@ class TradeFragment : BaseFragment() { toToken = tempToToken } } + + private suspend fun preloadTradeTitleSymbols() { + initialSwapToSymbol = resolveInitialToTokenSymbol(isLimit = false) + initialLimitToSymbol = resolveInitialToTokenSymbol(isLimit = true) + } + + private suspend fun resolveInitialToTokenSymbol(isLimit: Boolean): String? { + val output = requireArguments().getString(ARGS_OUTPUT) + if (!output.isNullOrBlank()) { + return findTokenSymbolByAssetId(output) + } + + val lastSelectedPairJson = defaultSharedPreferences.getString(getPreferenceKey(isLimit), null) + val lastSelectedPair: List? = lastSelectedPairJson?.let { + val type = object : TypeToken>() {}.type + GsonHelper.customGson.fromJson(it, type) + } + val lastToSymbol = lastSelectedPair?.getOrNull(1)?.symbol?.trim() + if (!lastToSymbol.isNullOrEmpty()) { + return lastToSymbol + } + + val input = requireArguments().getString(ARGS_INPUT) + if (input.isNullOrBlank()) { + return null + } + val fallbackOutputAssetId = if (input in Constants.usdIds) XIN_ASSET_ID else USDT_ASSET_ETH_ID + return findTokenSymbolByAssetId(fallbackOutputAssetId) + } + + private suspend fun findTokenSymbolByAssetId(assetId: String): String? { + return if (inMixin()) { + swapViewModel.findToken(assetId)?.symbol?.trim() + } else { + val currentWalletId = walletId ?: return null + swapViewModel.web3TokenItemById(currentWalletId, assetId)?.symbol?.trim() + }?.takeIf { it.isNotEmpty() } + } + private suspend fun refreshStocks(chainIds: List?) { val chainIdSet: Set? = chainIds?.toSet()?.takeIf { it.isNotEmpty() } requestRouteAPI( @@ -1005,5 +1049,7 @@ class TradeFragment : BaseFragment() { stocks = emptyList() tokenItems = null web3tokens = null + initialSwapToSymbol = null + initialLimitToSymbol = null } } 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 3716cb08a7..f9a84c4c2d 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 @@ -76,6 +76,8 @@ fun TradePage( swapTo: SwapToken?, limitFrom: SwapToken?, limitTo: SwapToken?, + initialSwapToSymbol: String?, + initialLimitToSymbol: String?, inMixin: Boolean, orderBadge: Boolean, isLimitOrderTabBadgeDismissed: Boolean, @@ -213,6 +215,13 @@ fun TradePage( initialPage = initialTabIndex.coerceIn(0, (tabCount - 1).coerceAtLeast(0)), pageCount = { tabCount }, ) + val currentTradeTitle = when (pagerState.currentPage) { + 0 -> swapTo.toTradeTitleOrNull(context.getString(R.string.Trade)) + ?: initialSwapToSymbol.toTradeTitleOrNull(context.getString(R.string.Trade)) + 1 -> limitTo.toTradeTitleOrNull(context.getString(R.string.Trade)) + ?: initialLimitToSymbol.toTradeTitleOrNull(context.getString(R.string.Trade)) + else -> null + } ?: stringResource(id = R.string.Trade) // When SwapContent requests switching to Limit tab, animate to it LaunchedEffect(switchToLimitRequested.value) { @@ -254,7 +263,7 @@ fun TradePage( } ) { PageScaffold( - title = stringResource(id = R.string.Trade), + title = currentTradeTitle, subtitle = { val text = if (walletId == null) { stringResource(id = R.string.Privacy_Wallet) @@ -441,3 +450,13 @@ fun checkBalance( } ?: return null return inputValue <= balanceValue } + +private fun SwapToken?.toTradeTitleOrNull(tradeLabel: String): String? { + return this?.symbol.toTradeTitleOrNull(tradeLabel) +} + +private fun String?.toTradeTitleOrNull(tradeLabel: String): String? { + val symbol = this?.trim().orEmpty() + if (symbol.isEmpty()) return null + return "$tradeLabel $symbol" +} From df1353d92510072774f69503ced0eeaaf313bbc2 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 16 Mar 2026 10:34:49 +0800 Subject: [PATCH 084/105] chore: remove remaining kapt config and standardize on ksp --- gradle.properties | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/gradle.properties b/gradle.properties index b5819dae34..1c0ff684b3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,14 +22,10 @@ org.gradle.unsafe.configuration-cache=true kotlin.incremental=true kotlin.caching.enabled=true -kapt.use.worker.api=true -kapt.incremental.apt=true -kapt.include.compile.classpath=false - android.r8.optimizedResourceShrinking=true android.defaults.buildfeatures.resvalues=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false android.enableAppCompileTimeRClass=false android.uniquePackageNames=false android.dependency.useConstraints=true -android.r8.strictFullModeForKeepRules=false \ No newline at end of file +android.r8.strictFullModeForKeepRules=false From 6bb6de9c75be17d7bfe9a539277c9364574626dd Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 16 Mar 2026 15:15:35 +0800 Subject: [PATCH 085/105] Update database schema --- .../one.mixin.android.db.PerpsDatabase/1.json | 12 +++--------- .../mixin/android/api/response/perps/PerpsMarket.kt | 4 ---- .../ui/home/web3/trade/perps/OpenPositionPage.kt | 5 +---- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json index d5e0339d89..4b7954fffa 100644 --- a/app/schemas/one.mixin.android.db.PerpsDatabase/1.json +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "523f621632813387fa5c95335f1ef136", + "identityHash": "5fad6bcd4df78e3928b3aa50627c3d8c", "entities": [ { "tableName": "positions", @@ -210,7 +210,7 @@ }, { "tableName": "markets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `min_amount` TEXT NOT NULL, `max_amount` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `amount` TEXT NOT NULL, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`market_id` TEXT NOT NULL, `display_symbol` TEXT NOT NULL, `token_symbol` TEXT NOT NULL, `quote_symbol` TEXT NOT NULL, `mark_price` TEXT NOT NULL, `leverage` INTEGER NOT NULL, `icon_url` TEXT NOT NULL, `funding_rate` TEXT NOT NULL, `min_amount` TEXT NOT NULL, `max_amount` TEXT NOT NULL, `last` TEXT NOT NULL, `volume` TEXT NOT NULL, `high` TEXT NOT NULL, `low` TEXT NOT NULL, `open` TEXT NOT NULL, `change` TEXT NOT NULL, `bid_price` TEXT NOT NULL, `ask_price` TEXT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`market_id`))", "fields": [ { "fieldPath": "marketId", @@ -284,12 +284,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "high", "columnName": "high", @@ -349,7 +343,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '523f621632813387fa5c95335f1ef136')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5fad6bcd4df78e3928b3aa50627c3d8c')" ] } } \ No newline at end of file diff --git a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt index 9db7c59074..d42daf3121 100644 --- a/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -55,10 +55,6 @@ data class PerpsMarket( @ColumnInfo(name = "volume") val volume: String, - @SerializedName("amount") - @ColumnInfo(name = "amount") - val amount: String, - @SerializedName("high") @ColumnInfo(name = "high") val high: String, 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 37cbcc397f..c35bed5362 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 @@ -585,8 +585,6 @@ fun OpenPositionPage( val price = m.markPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO if (price == BigDecimal.ZERO) return@Button - val orderValue = amount * BigDecimal(leverage.toDouble()) - errorInfo = null scope.launch { val hasOpeningPosition = viewModel.getOpenPositionsFromDb(walletId) @@ -600,12 +598,11 @@ fun OpenPositionPage( assetId = token.assetId, marketId = m.marketId, side = if (isLong) "long" else "short", - amount = orderValue.stripTrailingZeros().toPlainString(), + amount = amount.stripTrailingZeros().toPlainString(), leverage = leverage.toInt(), walletId = walletId, entryPrice = m.markPrice, onSuccess = { response -> - errorInfo = null PerpsConfirmBottomSheetDialogFragment.newInstance( marketSymbol = m.displaySymbol, marketIcon = m.iconUrl, From cf29fc8ecd4f0b0880b7d2e2460aca259a59e42c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 17 Mar 2026 10:12:05 +0800 Subject: [PATCH 086/105] Share --- .../web3/trade/perps/PerpsMarketDetailPage.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 8018544300..dcfe31b2b9 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 @@ -205,7 +205,10 @@ fun PerpsMarketDetailPage( if (currentPosition != null) { OpenPositionCard( position = currentPosition, - viewModel = viewModel + viewModel = viewModel, + onShare = { + PerpsPositionShareActivity.show(context, currentPosition) + }, ) Spacer(modifier = Modifier.height(16.dp)) } @@ -606,6 +609,7 @@ private fun MarketDetailCard( private fun OpenPositionCard( position: PerpsPositionItem, viewModel: PerpetualViewModel, + onShare: () -> Unit, ) { val context = LocalContext.current val quoteColorReversed = context.defaultSharedPreferences @@ -656,6 +660,15 @@ private fun OpenPositionCard( fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + painter = painterResource(id = R.drawable.ic_share_arrow), + contentDescription = null, + tint = MixinAppTheme.colors.accent, + modifier = Modifier + .size(24.dp) + .clickable(onClick = onShare), + ) } Spacer(modifier = Modifier.height(12.dp)) From 99c8de4e7a86754b19d9e6fc8b5c3a4daaa44c1f Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 17 Mar 2026 10:24:16 +0800 Subject: [PATCH 087/105] Update strings --- .../java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt | 4 ++-- .../main/java/one/mixin/android/ui/wallet/alert/AlertPage.kt | 4 ++-- .../java/one/mixin/android/ui/wallet/alert/AllAlertPage.kt | 4 ++-- app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt index c4fd589e6d..a9235863fc 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt @@ -158,7 +158,7 @@ fun AlertEditPage(coin: CoinItem?, alert: Alert?, onAdd: (CoinItem) -> Unit, pop }, ) { PageScaffold( - title = stringResource(id = if (alert == null) R.string.Add_Alert else R.string.Edit_Alert), + title = stringResource(id = if (alert == null) R.string.Alert else R.string.Edit_Alert), verticalScrollable = false, pop = pop, ) { @@ -508,7 +508,7 @@ fun AlertEditPage(coin: CoinItem?, alert: Alert?, onAdd: (CoinItem) -> Unit, pop } Text( modifier = Modifier.alpha(if (isLoading) 0f else 1f), - text = stringResource(if (alert == null) R.string.Add_Alert else R.string.Save), + text = stringResource(if (alert == null) R.string.Alert else R.string.Save), color = if (enable) Color.White else MixinAppTheme.colors.textAssist, ) } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertPage.kt b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertPage.kt index 7819642389..009dd2d933 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertPage.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertPage.kt @@ -97,7 +97,7 @@ fun AlertPage(coin: CoinItem, pop: () -> Unit, onAdd: () -> Unit, toAll: () -> U ) { Box(modifier = Modifier.padding(horizontal = 32.dp), contentAlignment = Alignment.Center) { Text( - text = stringResource(R.string.Add_Alert), + text = stringResource(R.string.Alert), color = Color.White, ) } @@ -135,7 +135,7 @@ fun AlertPage(coin: CoinItem, pop: () -> Unit, onAdd: () -> Unit, toAll: () -> U ) { Box(modifier = Modifier.padding(horizontal = 32.dp), contentAlignment = Alignment.Center) { Text( - text = stringResource(R.string.Add_Alert), + text = stringResource(R.string.Alert), color = Color.White, ) } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/alert/AllAlertPage.kt b/app/src/main/java/one/mixin/android/ui/wallet/alert/AllAlertPage.kt index 81814bdc6a..45b03626c3 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/alert/AllAlertPage.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/alert/AllAlertPage.kt @@ -82,7 +82,7 @@ fun AllAlertPage(coins: Set?, openFilter: () -> Unit, pop: () -> Unit, ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(R.string.Add_Alert), color = Color(0xFF3D75E3) + text = stringResource(R.string.Alert), color = Color(0xFF3D75E3) ) } } @@ -117,7 +117,7 @@ fun AllAlertPage(coins: Set?, openFilter: () -> Unit, pop: () -> Unit, ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(R.string.Add_Alert), color = Color(0xFF3D75E3) + text = stringResource(R.string.Alert), color = Color(0xFF3D75E3) ) } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 96d03c72e6..5d702e5f61 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1766,7 +1766,6 @@ 价格预警 编辑价格预警 没有价格预警 - 添加价格预警 当前价格:%1$s 预警类型 预警频率 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ba46d510e..50f7ee80ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1816,7 +1816,6 @@ Alert Edit Alert NO ALERTS - Add Alert Current price: %1$s Alert Type Frequency From e08b485d8dbd174d2f6288b34a4fcb390cdb80e1 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 17 Mar 2026 15:02:47 +0800 Subject: [PATCH 088/105] Remove strings --- .../ui/home/web3/trade/perps/PerpetualGuidePage.kt | 1 - app/src/main/res/values-zh-rCN/strings.xml | 13 ++++++------- app/src/main/res/values/strings.xml | 1 - 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index c39e1b88cf..ecc9a2f46c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -589,7 +589,6 @@ private fun GuideSection(title: String, content: String) { stringResource(R.string.Perpetual_Feature_2), stringResource(R.string.Perpetual_Feature_3), stringResource(R.string.Perpetual_Feature_4), - stringResource(R.string.Perpetual_Feature_5) ) .forEach { feature -> DotText( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5d702e5f61..b0db8b294b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -470,7 +470,7 @@ 错误 10614: 输入金额太小或太大,请重新输入。 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 - 错误 10650: 规模太小,请调整后重试。 + 错误 10650: 仓位规模太小,请调整后重试。 错误 10651:当前市场已开仓。 当前市场已开仓。 错误 20114:验证码已过期 @@ -2244,11 +2244,10 @@ 支持做多 / 做空,双向交易 最高支持 200 倍杠杆 支持逐仓模式,灵活控制风险 - 延迟爆仓,防插针 风险提示 杠杆交易可能放大收益,同时也会放大亏损。 当保证金不足时,仓位可能被强制平仓。 - 请合理控制杠杆倍数与规模,谨慎交易。 + 请合理控制杠杆倍数与仓位规模,谨慎交易。 举例说明 开仓交易 价格上涨 %1$s%% → 盈利 %2$s%% (+%3$s) @@ -2270,14 +2269,14 @@ 产生盈利 产生亏损 盈亏规则 - 杠杆倍数用于放大交易规模,以较少的保证金控制更大的合约仓位。 + 杠杆倍数用于放大交易仓位规模,以较少的保证金控制更大的合约仓位。 盈亏影响 杠杆会同时放大收益和亏损。 杠杆倍数越高,盈亏随价格波动的变化越大。 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 - 当前合约的规模由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 + 当前合约的仓位规模由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产仓位规模。 用途 - 决定本次交易的市场敞口规模。 + 决定本次交易的市场敞口仓位规模。 影响盈亏变化的放大倍数。 支撑当前仓位 抵扣浮动亏损 @@ -2288,7 +2287,7 @@ 杠杆倍数 选择代币 选择杠杆 - 规模 + 仓位规模 清算价格 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 50f7ee80ce..148d24c2e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2310,7 +2310,6 @@ Support long/short, bidirectional trading Up to 200x leverage supported Support isolated margin mode for flexible risk control - Delayed liquidation to prevent flash crashes Risk Warning Leverage trading can amplify gains but also losses. When margin is insufficient, positions may be forcibly liquidated. From 69faa9dff1823f9b6032c8e6f7cc5418de2ef80c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 17 Mar 2026 16:06:11 +0800 Subject: [PATCH 089/105] Update guide strings --- .../ui/home/web3/trade/perps/PerpetualGuidePage.kt | 10 +++++----- app/src/main/res/values-zh-rCN/strings.xml | 6 +++--- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index ecc9a2f46c..8ee9299600 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -206,7 +206,7 @@ private fun LongContent() { value = "${leverage}x" ), GuideRowData( - label = stringResource(R.string.Perpetual_Investment), + label = stringResource(R.string.Perpetual_Amount), value = "1,000 USDT" ) ), @@ -262,7 +262,7 @@ private fun ShortContent() { value = "${leverage}x" ), GuideRowData( - label = stringResource(R.string.Perpetual_Investment), + label = stringResource(R.string.Perpetual_Amount), value = "1,000 USDT" ) ), @@ -321,7 +321,7 @@ private fun LeverageContent() { value = "${leverage}x" ), GuideRowData( - label = stringResource(R.string.Perpetual_Investment), + label = stringResource(R.string.Perpetual_Amount), value = "1,000 USDT" ) ), @@ -403,7 +403,7 @@ private fun PositionContent() { value = "${leverage}x" ), GuideRowData( - label = stringResource(R.string.Perpetual_Investment), + label = stringResource(R.string.Perpetual_Amount), value = "${formatGuideInt(investment)} USDT" ), GuideRowData( @@ -654,7 +654,7 @@ private fun ExampleWithScenariosCard( } val directionLabel = stringResource(R.string.Direction) val leverageLabel = stringResource(R.string.Leverage) - val investmentLabel = stringResource(R.string.Perpetual_Investment) + val investmentLabel = stringResource(R.string.Perpetual_Amount) val longDirection = stringResource(R.string.Long) val shortDirection = stringResource(R.string.Short) Column( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b0db8b294b..984558dd95 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2254,7 +2254,7 @@ 价格下跌 %1$s%% → 盈利 %2$s%% (+%3$s) 价格下跌 %1$s%% → 亏损 -%2$s %3$s 价格上涨 %1$s%% → 亏损 -%2$s %3$s - 保证金 + 投入资金 场景一:价格上涨 场景二:价格下跌 场景%1$d:%2$s @@ -2269,14 +2269,14 @@ 产生盈利 产生亏损 盈亏规则 - 杠杆倍数用于放大交易仓位规模,以较少的保证金控制更大的合约仓位。 + 杠杆倍数用于放大交易仓位,以较少的保证金控制更大的合约仓位。 盈亏影响 杠杆会同时放大收益和亏损。 杠杆倍数越高,盈亏随价格波动的变化越大。 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 当前合约的仓位规模由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产仓位规模。 用途 - 决定本次交易的市场敞口仓位规模。 + 决定本次交易的市场敞口仓位。 影响盈亏变化的放大倍数。 支撑当前仓位 抵扣浮动亏损 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 148d24c2e2..4782933366 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2320,7 +2320,7 @@ Price down %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Loss -%2$s %3$s Price up %1$s%% → Loss -%2$s %3$s - Margin + Amount Scenario 1: Price Rise Scenario 2: Price Fall Scenario %1$d: %2$s From d9610d7a5d257b745c377030b72c05f7f8226b1d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 17 Mar 2026 16:40:24 +0800 Subject: [PATCH 090/105] Update string --- app/src/main/res/values-zh-rCN/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 984558dd95..7ef3f7d03d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2274,7 +2274,7 @@ 杠杆会同时放大收益和亏损。 杠杆倍数越高,盈亏随价格波动的变化越大。 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 - 当前合约的仓位规模由「保证金 × 杠杆倍数」计算得出,表示本次交易所控制的资产仓位规模。 + 当前合约的仓位规模由「投入资金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 用途 决定本次交易的市场敞口仓位。 影响盈亏变化的放大倍数。 From e1a3f33b3ffe2ef05f51f35b59030aab61ebbf64 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 17 Mar 2026 22:05:41 +0800 Subject: [PATCH 091/105] fix(wallet): secure mnemonic and private-key export flows --- .../ui/common/QrBottomSheetDialogFragment.kt | 15 +++++++++++++ .../ui/wallet/WalletSecurityActivity.kt | 21 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/common/QrBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/common/QrBottomSheetDialogFragment.kt index 9b684207aa..3ad00369a1 100644 --- a/app/src/main/java/one/mixin/android/ui/common/QrBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/common/QrBottomSheetDialogFragment.kt @@ -4,8 +4,10 @@ import android.annotation.SuppressLint import android.app.Dialog import android.graphics.Bitmap import android.os.Build +import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowManager import androidx.appcompat.view.ContextThemeWrapper import androidx.core.net.toUri import androidx.core.os.bundleOf @@ -68,6 +70,19 @@ class QrBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { private val binding by viewBinding(FragmentQrBottomSheetBinding::inflate) + override fun onCreateDialog(savedInstanceState: Bundle?): BottomSheet { + return super.onCreateDialog(savedInstanceState).apply { + if (type == TYPE_MNEMONIC_QR) { + window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + + override fun onDestroyView() { + dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + super.onDestroyView() + } + @SuppressLint("RestrictedApi") override fun setupDialog( dialog: Dialog, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletSecurityActivity.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletSecurityActivity.kt index 4f54591fc6..c78974541e 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletSecurityActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletSecurityActivity.kt @@ -2,18 +2,25 @@ package one.mixin.android.ui.wallet import android.app.Activity import android.os.Bundle +import android.view.WindowManager import dagger.hilt.android.AndroidEntryPoint import one.mixin.android.R import one.mixin.android.ui.common.BlazeBaseActivity @AndroidEntryPoint class WalletSecurityActivity : BlazeBaseActivity() { + private val mode: Mode by lazy { + val modeOrdinal = intent.getIntExtra(EXTRA_MODE, Mode.IMPORT_MNEMONIC.ordinal) + Mode.entries[modeOrdinal] + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (mode.requiresSecureWindow()) { + window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } setContentView(R.layout.activity_add_wallet) if (savedInstanceState == null) { - val modeOrdinal = intent.getIntExtra(EXTRA_MODE, Mode.IMPORT_MNEMONIC.ordinal) - val mode = Mode.entries[modeOrdinal] val chainId = intent.getStringExtra(EXTRA_CHAIN_ID) val walletId = intent.getStringExtra(EXTRA_WALLET_ID) @@ -35,6 +42,13 @@ class WalletSecurityActivity : BlazeBaseActivity() { } } + override fun onDestroy() { + if (mode.requiresSecureWindow()) { + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + super.onDestroy() + } + companion object { const val EXTRA_MODE = "extra_mode" const val EXTRA_CHAIN_ID = "extra_chain_id" @@ -61,6 +75,9 @@ class WalletSecurityActivity : BlazeBaseActivity() { VIEW_ADDRESS, } + private fun Mode.requiresSecureWindow(): Boolean = + this == Mode.VIEW_MNEMONIC || this == Mode.VIEW_PRIVATE_KEY + override fun onBackPressed() { super.onBackPressed() finish() From 91a31c21c8d32fa8111aea73f4944c93874aaf14 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 17 Mar 2026 22:34:51 +0800 Subject: [PATCH 092/105] fix(wallet): restore token picker assets for transfer and perps --- .../android/ui/common/BottomSheetViewModel.kt | 5 ++ .../home/web3/trade/perps/OpenPositionPage.kt | 6 +- .../TokenListBottomSheetDialogFragment.kt | 55 ++++++++++++------- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt index 84ae5ba48a..c73dbe479c 100644 --- a/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt +++ b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt @@ -216,6 +216,11 @@ class BottomSheetViewModel fun assetItemsWithBalance(): LiveData> = tokenRepository.assetItemsWithBalance() + suspend fun findAssetItemsWithBalance(): List = + withContext(Dispatchers.IO) { + tokenRepository.findAssetItemsWithBalance() + } + fun usdAssetItemsWithBalance(): LiveData> = tokenRepository.usdAssetItemsWithBalance() fun assetItemsNotHidden(): LiveData> = tokenRepository.assetItemsNotHidden() 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 c35bed5362..e483786dc8 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 @@ -129,7 +129,11 @@ fun OpenPositionPage( LaunchedEffect(acceptedPerpAssetIds) { viewModel.loadUsdTokens { tokens -> - val supportedTokens = tokens.filter { it.assetId in acceptedPerpAssetIds } + val supportedTokens = if (acceptedPerpAssetIds.isEmpty()) { + tokens + } else { + tokens.filter { it.assetId in acceptedPerpAssetIds } + } availableTokens = supportedTokens currentToken = selectedToken?.let { target -> supportedTokens.firstOrNull { it.assetId == target.assetId } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt index 59e12daf3a..7eac8f0cc5 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt @@ -62,6 +62,7 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { const val TYPE_FROM_SEND = 0 const val TYPE_FROM_RECEIVE = 1 + const val TYPE_FROM_TRANSFER = 2 const val TYPE_FROM_PERP = 3 @@ -107,6 +108,32 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { .toSet() } + private fun filterAssets(items: List): List = + if (fromType == TYPE_FROM_PERP) { + if (acceptedPerpAssetIds.isEmpty()) { + items + } else { + items.filter { token -> token.assetId in acceptedPerpAssetIds } + } + } else { + items + } + + private fun updateDefaultAssets( + items: List, + ) { + val filteredAssets = filterAssets(items) + defaultAssets = filteredAssets + if (binding.searchEt.et.text.isNullOrBlank()) { + adapter.submitList(defaultAssets) + binding.rvVa.displayedChild = if (defaultAssets.isEmpty()) { + POS_EMPTY_RECEIVE + } else { + POS_RV + } + } + } + private fun initRadio() { binding.apply { if (fromType == TYPE_FROM_PERP) { @@ -239,29 +266,16 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { } if (fromType == TYPE_FROM_SEND || fromType == TYPE_FROM_TRANSFER) { - bottomViewModel.assetItemsWithBalance() + lifecycleScope.launch { + val snapshot = bottomViewModel.findAssetItemsWithBalance() + updateDefaultAssets(snapshot) + } } else if (fromType == TYPE_FROM_PERP) { bottomViewModel.usdAssetItemsWithBalance() } else { bottomViewModel.assetItemsNotHidden() - }.observe(this) { - defaultAssets = if (fromType == TYPE_FROM_PERP) { - it.filter { token -> token.assetId in acceptedPerpAssetIds } - } else { - it - } - if (fromType == TYPE_FROM_SEND) { - adapter.submitList(defaultAssets) - if (defaultAssets.isEmpty()) { - binding.rvVa.displayedChild = POS_EMPTY_RECEIVE - } else { - binding.rvVa.displayedChild = POS_RV - } - } else { - if (binding.searchEt.et.text.isNullOrBlank()) { - adapter.submitList(defaultAssets) - } - } + }.observe(this) { items -> + updateDefaultAssets(items) } } @@ -313,7 +327,8 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { private fun loadData() { adapter.chain = currentChain - binding.rvVa.displayedChild = when (adapter.getFilteredTokens().size) { + val filteredCount = adapter.getFilteredTokens().size + binding.rvVa.displayedChild = when (filteredCount) { 0 -> POS_EMPTY_RECEIVE else -> POS_RV } From e57e08d3b7866fe42cecfc3bd39fa4443423d975 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Tue, 17 Mar 2026 19:17:59 +0800 Subject: [PATCH 093/105] Volume format --- .../ui/home/web3/trade/perps/PerpsMarketDetailPage.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index dcfe31b2b9..ec6238f15b 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 @@ -59,6 +59,7 @@ import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.extension.marketPriceFormat +import one.mixin.android.extension.numberFormatCompact import one.mixin.android.extension.openUrl import one.mixin.android.extension.priceFormat import one.mixin.android.session.Session @@ -454,6 +455,7 @@ private fun MarketInfoCard( } } +@Composable private fun formatVolume( volume: String, fiatRate: BigDecimal, @@ -461,9 +463,9 @@ private fun formatVolume( ): String { return try { val fiatVolume = BigDecimal(volume).multiply(fiatRate) - "${fiatSymbol}${fiatVolume.priceFormat()}" - } catch (e: Exception) { - "${fiatSymbol}${volume}" + "${fiatSymbol}${fiatVolume.numberFormatCompact()}" + } catch (e: NumberFormatException) { + stringResource(R.string.N_A) } } From bdd4657e4b9a4098750d641a81bb0759f866e94b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 18 Mar 2026 15:54:22 +0800 Subject: [PATCH 094/105] Update strings --- .../home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt | 2 +- .../mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt index c2aa90f498..9d0d100702 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt @@ -155,7 +155,7 @@ private fun LeverageContent( .padding(20.dp) ) { Text( - text = stringResource(R.string.Leverage), + text = stringResource(R.string.Leverage_Short), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MixinAppTheme.colors.textPrimary 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 e483786dc8..35469df52c 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 @@ -369,7 +369,7 @@ fun OpenPositionPage( Text( - text = stringResource(R.string.Leverage), + text = stringResource(R.string.Leverage_Short), fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b673a3aa74..117fd52839 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2285,6 +2285,7 @@ 永续合约 开仓 杠杆倍数 + 杠杆 选择代币 选择杠杆 仓位规模 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8fbc5bb275..91b17fb4a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2351,6 +2351,7 @@ Perpetual Open Position Leverage Multiplier + Leverage Select Token Select Leverage Position Scale From 3ec8e2a82565cca5262e4663be34e023ca8d1d1b Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 18 Mar 2026 16:23:23 +0800 Subject: [PATCH 095/105] Update strings --- .../trade/perps/LeverageBottomSheetDialogFragment.kt | 2 +- .../ui/home/web3/trade/perps/OpenPositionPage.kt | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 3 +-- app/src/main/res/values/strings.xml | 11 +++++------ 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt index 9d0d100702..c2aa90f498 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt @@ -155,7 +155,7 @@ private fun LeverageContent( .padding(20.dp) ) { Text( - text = stringResource(R.string.Leverage_Short), + text = stringResource(R.string.Leverage), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MixinAppTheme.colors.textPrimary 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 35469df52c..e483786dc8 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 @@ -369,7 +369,7 @@ fun OpenPositionPage( Text( - text = stringResource(R.string.Leverage_Short), + text = stringResource(R.string.Leverage), fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary ) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 117fd52839..41b487488d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2284,8 +2284,7 @@ 价格剧烈波动可能会快速消耗投入资金。 永续合约 开仓 - 杠杆倍数 - 杠杆 + 杠杆 选择代币 选择杠杆 仓位规模 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 91b17fb4a1..8b7a4a1fe8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2315,7 +2315,7 @@ When margin is insufficient, positions may be forcibly liquidated. Please control leverage and position size reasonably and trade cautiously. Example - Opening Trade + Perpetual Price up %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Loss -%2$s %3$s @@ -2335,7 +2335,7 @@ Profit Loss P&L Rules - Leverage multiplier is used to amplify trading size, controlling larger contract positions with less margin. + Leverage is used to amplify trading size, controlling larger contract positions with less margin. P&L Impact Leverage amplifies both gains and losses. Higher leverage means greater P&L fluctuation with price movements. @@ -2350,11 +2350,10 @@ Severe price volatility may rapidly consume investment. Perpetual Open Position - Leverage Multiplier - Leverage + Leverage Select Token Select Leverage - Position Scale + Size Liquidation Price Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) Long @@ -2403,7 +2402,7 @@ No Positions No Activities PNL - Opening Direction + Direction No Markets View More Loading... From 81066f5e21a882096811b159227a530fe07f1f2d Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 18 Mar 2026 18:28:04 +0800 Subject: [PATCH 096/105] Replace key --- .../web3/trade/perps/PerpetualGuidePage.kt | 96 +++++++++---------- .../PerpsCloseBottomSheetDialogFragment.kt | 6 +- .../PerpsConfirmBottomSheetDialogFragment.kt | 4 +- .../web3/trade/perps/PositionDetailPage.kt | 8 +- app/src/main/res/values-zh-rCN/strings.xml | 82 ++++++++-------- app/src/main/res/values/strings.xml | 82 ++++++++-------- 6 files changed, 139 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 8ee9299600..83203168f2 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -181,7 +181,7 @@ fun PerpetualGuidePage( private fun OverviewContent() { GuideSection( title = stringResource(R.string.Perpetual_Guide_Overview_Title), - content = stringResource(R.string.Perpetual_Guide_Overview_Desc) + content = stringResource(R.string.perps_intro_overview) ) } @@ -190,10 +190,10 @@ private fun LongContent() { val leverage = 10 val maxLossPercent = 100f / leverage ExampleWithScenariosCard( - title = stringResource(R.string.Perpetual_Example), + title = stringResource(R.string.Example), rows = listOf( GuideRowData( - label = stringResource(R.string.Perpetual_Trading_Pair), + label = stringResource(R.string.example_perpetual), value = "BTC - USD", iconRes = R.drawable.ic_chain_btc ), @@ -206,14 +206,14 @@ private fun LongContent() { value = "${leverage}x" ), GuideRowData( - label = stringResource(R.string.Perpetual_Amount), + label = stringResource(R.string.example_amount), value = "1,000 USDT" ) ), scenarios = listOf( ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), - change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + change = stringResource(R.string.example_price_increased), initialPercent = maxLossPercent, basePnlAmount = 1000, basePnlPercent = 100, @@ -222,7 +222,7 @@ private fun LongContent() { ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), - change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + change = stringResource(R.string.example_price_decreased), initialPercent = maxLossPercent, basePnlAmount = 1000, basePnlPercent = 100, @@ -233,7 +233,7 @@ private fun LongContent() { ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithRulesCard( - description = stringResource(R.string.Perpetual_Long_Desc), + description = stringResource(R.string.perps_long_overview), rules = listOf( stringResource(R.string.Perpetual_Price_Up) to stringResource(R.string.Perpetual_Profit), stringResource(R.string.Perpetual_Price_Down) to stringResource(R.string.Perpetual_Loss) @@ -246,10 +246,10 @@ private fun ShortContent() { val leverage = 10 val maxLossPercent = 100f / leverage ExampleWithScenariosCard( - title = stringResource(R.string.Perpetual_Example), + title = stringResource(R.string.Example), rows = listOf( GuideRowData( - label = stringResource(R.string.Perpetual_Trading_Pair), + label = stringResource(R.string.example_perpetual), value = "ETH - USD", iconRes = R.drawable.ic_chain_eth ), @@ -262,14 +262,14 @@ private fun ShortContent() { value = "${leverage}x" ), GuideRowData( - label = stringResource(R.string.Perpetual_Amount), + label = stringResource(R.string.example_amount), value = "1,000 USDT" ) ), scenarios = listOf( ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), - change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + change = stringResource(R.string.example_price_decreased), initialPercent = maxLossPercent, basePnlAmount = 1000, basePnlPercent = 100, @@ -278,7 +278,7 @@ private fun ShortContent() { ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), - change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + change = stringResource(R.string.example_price_increased), initialPercent = maxLossPercent, basePnlAmount = 1000, basePnlPercent = 100, @@ -289,7 +289,7 @@ private fun ShortContent() { ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithRulesCard( - description = stringResource(R.string.Perpetual_Short_Desc), + description = stringResource(R.string.perps_short_overview), rules = listOf( stringResource(R.string.Perpetual_Price_Down) to stringResource(R.string.Perpetual_Profit), stringResource(R.string.Perpetual_Price_Up) to stringResource(R.string.Perpetual_Loss) @@ -305,10 +305,10 @@ private fun LeverageContent() { val profitPnlAmount = leverage * 100 val profitPnlPercent = leverage * 10 ExampleWithScenariosCard( - title = stringResource(R.string.Perpetual_Example), + title = stringResource(R.string.Example), rows = listOf( GuideRowData( - label = stringResource(R.string.Perpetual_Trading_Pair), + label = stringResource(R.string.example_perpetual), value = "SOL - USD", iconRes = R.drawable.ic_chain_sol ), @@ -321,14 +321,14 @@ private fun LeverageContent() { value = "${leverage}x" ), GuideRowData( - label = stringResource(R.string.Perpetual_Amount), + label = stringResource(R.string.example_amount), value = "1,000 USDT" ) ), scenarios = listOf( ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), - change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + change = stringResource(R.string.example_price_increased), initialPercent = fixedProfitPercent, basePnlAmount = profitPnlAmount, basePnlPercent = profitPnlPercent, @@ -337,7 +337,7 @@ private fun LeverageContent() { ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), - change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + change = stringResource(R.string.example_price_decreased), initialPercent = liquidationPercent, basePnlAmount = 1000, basePnlPercent = 100, @@ -351,13 +351,13 @@ private fun LeverageContent() { ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithInfoAndRiskCard( - description = stringResource(R.string.Perpetual_Leverage_Desc), - infoTitle = stringResource(R.string.Perpetual_PnL_Impact), + description = stringResource(R.string.perps_leverage_overview), + infoTitle = stringResource(R.string.impact_on_pnl), infoContents = listOf( - stringResource(R.string.Perpetual_Leverage_Impact_1), - stringResource(R.string.Perpetual_Leverage_Impact_2) + stringResource(R.string.impact_on_pnl_1), + stringResource(R.string.impact_on_pnl_2) ), - riskContents = listOf(stringResource(R.string.Perpetual_Leverage_Risk)) + riskContents = listOf(stringResource(R.string.perps_leverage_risk_notice)) ) } @@ -387,10 +387,10 @@ private fun PositionContent() { val lossPnlPercent = 100 ExampleWithScenariosCard( - title = stringResource(R.string.Perpetual_Example), + title = stringResource(R.string.Example), rows = listOf( GuideRowData( - label = stringResource(R.string.Perpetual_Trading_Pair), + label = stringResource(R.string.example_perpetual), value = "SOL - USD", iconRes = R.drawable.ic_chain_sol ), @@ -403,7 +403,7 @@ private fun PositionContent() { value = "${leverage}x" ), GuideRowData( - label = stringResource(R.string.Perpetual_Amount), + label = stringResource(R.string.example_amount), value = "${formatGuideInt(investment)} USDT" ), GuideRowData( @@ -414,7 +414,7 @@ private fun PositionContent() { scenarios = listOf( ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Up), - change = stringResource(R.string.Perpetual_Price_Up_Amplitude), + change = stringResource(R.string.example_price_increased), initialPercent = fixedProfitPercent, basePnlAmount = profitPnlAmount, basePnlPercent = profitPnlPercent, @@ -423,7 +423,7 @@ private fun PositionContent() { ), ScenarioData( scenario = stringResource(R.string.Perpetual_Price_Down), - change = stringResource(R.string.Perpetual_Price_Down_Amplitude), + change = stringResource(R.string.example_price_decreased), initialPercent = maxLossPercent, basePnlAmount = lossPnlAmount, basePnlPercent = lossPnlPercent, @@ -441,13 +441,13 @@ private fun PositionContent() { ) Spacer(modifier = Modifier.height(16.dp)) DescriptionWithInfoAndRiskCard( - description = stringResource(R.string.Perpetual_Position_Desc), - infoTitle = stringResource(R.string.Perpetual_Position_Usage), + description = stringResource(R.string.perps_position_size_overview), + infoTitle = stringResource(R.string.Purpose), infoContents = listOf( - stringResource(R.string.Perpetual_Position_Usage_Desc_1), - stringResource(R.string.Perpetual_Position_Usage_Desc_2) + stringResource(R.string.perps_position_size_purpose_1), + stringResource(R.string.perps_position_size_purpose_2) ), - riskContents = listOf(stringResource(R.string.Perpetual_Position_Risk_1) , stringResource(R.string.Perpetual_Position_Risk_2)) + riskContents = listOf(stringResource(R.string.perps_position_size_risk_1) , stringResource(R.string.perps_position_size_risk_2)) ) } @@ -578,17 +578,17 @@ private fun GuideSection(title: String, content: String) { ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.Perpetual_Features), + text = stringResource(R.string.Product_Features), fontSize = 16.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(8.dp)) listOf( - stringResource(R.string.Perpetual_Feature_1), - stringResource(R.string.Perpetual_Feature_2), - stringResource(R.string.Perpetual_Feature_3), - stringResource(R.string.Perpetual_Feature_4), + stringResource(R.string.product_features_1), + stringResource(R.string.product_features_2), + stringResource(R.string.product_features_3, 200), + stringResource(R.string.product_features_4), ) .forEach { feature -> DotText( @@ -600,22 +600,22 @@ private fun GuideSection(title: String, content: String) { Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.Perpetual_Risk_Warning), + text = stringResource(R.string.Risk_Notice), fontSize = 16.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary ) Spacer(modifier = Modifier.height(8.dp)) DotText( - text = stringResource(R.string.Perpetual_Risk_Warning_Content_1), + text = stringResource(R.string.perps_intro_risk_notice_1), color = MixinAppTheme.colors.textPrimary ) DotText( - text = stringResource(R.string.Perpetual_Risk_Warning_Content_2), + text = stringResource(R.string.perps_intro_risk_notice_2), color = MixinAppTheme.colors.textPrimary ) DotText( - text = stringResource(R.string.Perpetual_Risk_Warning_Content_3), + text = stringResource(R.string.perps_intro_risk_notice_3), color = MixinAppTheme.colors.textPrimary ) } @@ -654,7 +654,7 @@ private fun ExampleWithScenariosCard( } val directionLabel = stringResource(R.string.Direction) val leverageLabel = stringResource(R.string.Leverage) - val investmentLabel = stringResource(R.string.Perpetual_Amount) + val investmentLabel = stringResource(R.string.example_amount) val longDirection = stringResource(R.string.Long) val shortDirection = stringResource(R.string.Short) Column( @@ -844,7 +844,7 @@ private fun ExampleWithScenariosCard( Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth()) { Text( - text = stringResource(R.string.Perpetual_PnL), + text = stringResource(R.string.PnL), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, modifier = Modifier.weight(1f) @@ -970,7 +970,7 @@ private fun DescriptionWithRulesCard( .padding(16.dp) ) { Text( - text = stringResource(R.string.Perpetual_Detail_Desc), + text = stringResource(R.string.Overview), fontSize = 16.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary @@ -986,7 +986,7 @@ private fun DescriptionWithRulesCard( Spacer(modifier = Modifier.height(16.dp)) Text( - text = stringResource(R.string.Perpetual_PnL_Rules), + text = stringResource(R.string.PnL_Rules), fontSize = 16.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary @@ -1016,7 +1016,7 @@ private fun DescriptionWithInfoAndRiskCard( .padding(16.dp) ) { Text( - text = stringResource(R.string.Perpetual_Detail_Desc), + text = stringResource(R.string.Overview), fontSize = 16.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary @@ -1058,7 +1058,7 @@ private fun DescriptionWithInfoAndRiskCard( .fillMaxWidth() ) { Text( - text = stringResource(R.string.Perpetual_Risk_Warning), + text = stringResource(R.string.Risk_Notice), fontSize = 14.sp, fontWeight = FontWeight.W500, color = MixinAppTheme.colors.textPrimary 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 28d1723e83..2106f31c5a 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 @@ -311,8 +311,8 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Text( text = stringResource( id = when (step) { - Step.Pending -> R.string.Confirm_Close_Position - Step.Done -> R.string.Close_Position_Success + Step.Pending -> R.string.confirm_closing_position + Step.Done -> R.string.Position_Closed Step.Error -> R.string.swap_failed Step.Sending -> R.string.Sending } @@ -450,7 +450,7 @@ class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragmen Spacer(modifier = Modifier.height(4.dp)) Row { Text( - text = "${stringResource(R.string.Perpetual_PnL)}: ", + text = "${stringResource(R.string.PnL)}: ", color = MixinAppTheme.colors.textAssist, fontSize = 14.sp ) 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 9630620798..e211e9ef30 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 @@ -276,8 +276,8 @@ class PerpsConfirmBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragm Text( text = stringResource( id = when (step) { - Step.Pending -> R.string.Confirm_Open_Position - Step.Done -> R.string.Open_Position_Success + Step.Pending -> R.string.confirm_opening_position + Step.Done -> R.string.Position_Opened Step.Error -> R.string.swap_failed Step.Sending -> R.string.Sending } 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 6c3f6f5835..8ec9c85e14 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 @@ -80,9 +80,9 @@ fun PositionDetailPage( stringResource(R.string.Short) } val title = if (isLong) { - stringResource(R.string.Perpetual_Opened_Long_Title) + stringResource(R.string.Opened_Long) } else { - stringResource(R.string.Perpetual_Opened_Short_Title) + stringResource(R.string.Opened_Short) } val quantity = position.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO @@ -388,9 +388,9 @@ fun PositionDetailPage( stringResource(R.string.Short) } val title = if (isLong) { - stringResource(R.string.Perpetual_Closed_Long_Title) + stringResource(R.string.Closed_Long) } else { - stringResource(R.string.Perpetual_Closed_Short_Title) + stringResource(R.string.Closed_Short) } val quantity = positionHistory.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 41b487488d..6d1d4daf75 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2238,50 +2238,50 @@ 杠杆允许您用更少的资金控制更大的仓位。例如,使用 10 倍杠杆,1%% 的价格变动会导致 10%% 的盈亏。杠杆越高,风险越大。 您可以随时平仓以实现盈亏。平仓价格基于当前市场价格。请务必监控您的仓位以避免爆仓。 具体说明 - Mixin 永续合约是一种以数字资产结算的衍生品交易方式,支持做多和做空,无到期日。通过杠杆机制,交易者可以放大仓位,把握价格上涨或下跌带来的交易机会。 - 产品特点 - 无到期日,可长期持仓 - 支持做多 / 做空,双向交易 - 最高支持 200 倍杠杆 - 支持逐仓模式,灵活控制风险 - 风险提示 - 杠杆交易可能放大收益,同时也会放大亏损。 - 当保证金不足时,仓位可能被强制平仓。 - 请合理控制杠杆倍数与仓位规模,谨慎交易。 - 举例说明 - 开仓交易 + Mixin 永续合约是一种以数字资产结算的衍生品交易方式,支持做多和做空,无到期日。通过杠杆机制,交易者可以放大仓位,把握价格上涨或下跌带来的交易机会。 + 产品特点: + 无到期日,可长期持仓 + 支持做多 / 做空,双向交易 + 最高支持 %d 倍杠杆 + 支持逐仓模式,灵活控制风险 + 风险提示: + 杠杆交易可能放大收益,同时也会放大亏损 + 当保证金不足时,仓位可能被强制平仓 + 请合理控制杠杆倍数与仓位规模,谨慎交易 + 举例说明 + 开仓交易 价格上涨 %1$s%% → 盈利 %2$s%% (+%3$s) 价格下跌 %1$s%% → 盈利 %2$s%% (+%3$s) 价格下跌 %1$s%% → 亏损 -%2$s %3$s 价格上涨 %1$s%% → 亏损 -%2$s %3$s - 投入资金 - 场景一:价格上涨 - 场景二:价格下跌 + 投入资金 + 场景一:价格上涨 + 场景二:价格下跌 场景%1$d:%2$s 价格上涨 价格下跌 - 上涨幅度 - 下跌幅度 - 盈亏 - 具体说明 - 做多是指在预期价格上涨时建立仓位,价格上涨可获得盈利,价格下跌则产生亏损。 - 做空是指在预期价格下跌时建立仓位,价格下跌可获得盈利,价格上涨则产生亏损。 + 上涨幅度 + 下跌幅度 + 盈亏 + 具体说明 + 做多是指在预期价格上涨时建立仓位,价格上涨可获得盈利,价格下跌则产生亏损。 + 做空是指在预期价格下跌时建立仓位,价格下跌可获得盈利,价格上涨则产生亏损。 产生盈利 产生亏损 - 盈亏规则 - 杠杆倍数用于放大交易仓位,以较少的保证金控制更大的合约仓位。 - 盈亏影响 - 杠杆会同时放大收益和亏损。 - 杠杆倍数越高,盈亏随价格波动的变化越大。 - 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 - 当前合约的仓位规模由「投入资金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 - 用途 - 决定本次交易的市场敞口仓位。 - 影响盈亏变化的放大倍数。 + 盈亏规则: + 杠杆倍数用于放大交易规模,以较少的保证金控制更大的合约仓位。 + 盈亏影响: + 杠杆会同时放大收益和亏损 + 杠杆倍数越高,盈亏随价格波动的变化越大 + 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 + 当前合约的仓位规模由「投入资金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 + 用途: + 决定本次交易的市场敞口规模 + 影响盈亏变化的放大倍数 支撑当前仓位 抵扣浮动亏损 - 当亏损接近已投入资金时,可能被系统强制平仓。 - 价格剧烈波动可能会快速消耗投入资金。 + 当亏损接近已投入资金时,可能被系统强制平仓。 + 价格剧烈波动可能会快速消耗投入资金。 永续合约 开仓 杠杆 @@ -2307,15 +2307,15 @@ 开仓时间 平仓时间 Mixin Futures - 确认开仓 - 开仓成功 - 确认平仓 - 平仓成功 + 确认开仓 + 开仓成功 + 确认平仓 + 平仓成功 平仓 - 开仓做多 - 开仓做空 - 平仓做多 - 平仓做空 + 开仓做多 + 开仓做空 + 平仓做多 + 平仓做空 预估清算价格 持仓总价值 保证金 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8b7a4a1fe8..bd9785e3cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2304,50 +2304,50 @@ Leverage allows you to control a larger position with less capital. For example, with 10x leverage, a 1%% price move results in a 10%% profit or loss. Higher leverage means higher risk. You can close your position at any time to realize your profit or loss. The closing price is based on the current market price. Make sure to monitor your positions to avoid liquidation. Instructions - Mixin perpetual contracts are derivative trading instruments settled in digital assets, supporting long and short positions with no expiration date. Through leverage, traders can amplify positions to capture trading opportunities from price movements. - Product Features - No expiration date, can hold positions long-term - Support long/short, bidirectional trading - Up to 200x leverage supported - Support isolated margin mode for flexible risk control - Risk Warning - Leverage trading can amplify gains but also losses. - When margin is insufficient, positions may be forcibly liquidated. - Please control leverage and position size reasonably and trade cautiously. - Example - Perpetual + Mixin perpetual contracts are derivative trading instruments settled in digital assets, supporting long and short positions with no expiration date. Through leverage, traders can amplify positions to capture trading opportunities from price movements. + Product Features + No expiration date, can hold positions long-term + Support long/short, bidirectional trading + Up to %1$dx leverage supported + Support isolated margin mode for flexible risk control + Risk Warning + Leverage trading can amplify gains but also losses. + When margin is insufficient, positions may be forcibly liquidated. + Please control leverage and position size reasonably and trade cautiously. + Example + Perpetual Price up %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Loss -%2$s %3$s Price up %1$s%% → Loss -%2$s %3$s - Amount - Scenario 1: Price Rise - Scenario 2: Price Fall + Amount + Scenario 1: Price Rise + Scenario 2: Price Fall Scenario %1$d: %2$s Price Rise Price Fall - Upward Change - Downward Change - P&L - Detailed Description - Going long means establishing a position when expecting price to rise. Profit when price rises, loss when price falls. - Going short means establishing a position when expecting price to fall. Profit when price falls, loss when price rises. + Upward Change + Downward Change + P&L + Detailed Description + Going long means establishing a position when expecting price to rise. Profit when price rises, loss when price falls. + Going short means establishing a position when expecting price to fall. Profit when price falls, loss when price rises. Profit Loss - P&L Rules - Leverage is used to amplify trading size, controlling larger contract positions with less margin. - P&L Impact - Leverage amplifies both gains and losses. - Higher leverage means greater P&L fluctuation with price movements. - Please choose leverage reasonably, with high leverage, even small price movements can lead to significant losses. - The position size of the current contract is calculated by "Margin × Leverage", representing the asset size controlled in this transaction. - Usage - Determines the market exposure size of this transaction. - Affects the amplification factor of profit and loss changes. + P&L Rules + Leverage is used to amplify trading size, controlling larger contract positions with less margin. + P&L Impact + Leverage amplifies both gains and losses. + Higher leverage means greater P&L fluctuation with price movements. + Please choose leverage reasonably, with high leverage, even small price movements can lead to significant losses. + The position size of the current contract is calculated by "Margin × Leverage", representing the asset size controlled in this transaction. + Usage + Determines the market exposure size of this transaction. + Affects the amplification factor of profit and loss changes. Support current position. Offset floating losses. - When losses approach the invested capital, a forced liquidation by the system may occur. - Severe price volatility may rapidly consume investment. + When losses approach the invested capital, a forced liquidation by the system may occur. + Severe price volatility may rapidly consume investment. Perpetual Open Position Leverage @@ -2379,15 +2379,15 @@ 24H VOLUME Open Interest Funding Rate - Confirm Open Position - Open Position Success - Confirm Close Position - Close Position Success + Confirm Open Position + Open Position Success + Confirm Close Position + Close Position Success Close Position - Open Long - Open Short - Close Long - Close Short + Open Long + Open Short + Close Long + Close Short Estimated Liquidation Price Total Position Value %1$s %2$s From 0bea42a7a35b0b1df4c2ffe388bb1e51b38bbf5c Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 19 Mar 2026 10:24:10 +0800 Subject: [PATCH 097/105] Revert "feat: trade page title uses to-token symbol for swap tabs" This reverts commit 8d322232ffeb6a35b2daacf352991a026b60235b. --- .../ui/home/web3/trade/TradeFragment.kt | 46 ------------------- .../android/ui/home/web3/trade/TradePage.kt | 21 +-------- 2 files changed, 1 insertion(+), 66 deletions(-) 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 b9e90679e0..17091c54ee 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 @@ -147,8 +147,6 @@ class TradeFragment : BaseFragment() { private var toToken: SwapToken? by mutableStateOf(null) private var limitFromToken: SwapToken? by mutableStateOf(null) private var limitToToken: SwapToken? by mutableStateOf(null) - private var initialSwapToSymbol: String? by mutableStateOf(null) - private var initialLimitToSymbol: String? by mutableStateOf(null) private var initialAmount: String? = null private var lastOrderTime: Long by mutableLongStateOf(0) @@ -188,7 +186,6 @@ class TradeFragment : BaseFragment() { ): View { initAmount() lifecycleScope.launch { - preloadTradeTitleSymbols() val chainIds = walletId?.let { swapViewModel.getAddresses(it).map { it.chainId @@ -284,8 +281,6 @@ class TradeFragment : BaseFragment() { swapTo = toToken, limitFrom = limitFromToken, limitTo = limitToToken, - initialSwapToSymbol = initialSwapToSymbol, - initialLimitToSymbol = initialLimitToSymbol, inMixin = inMixin(), orderBadge = orderBadge, isLimitOrderTabBadgeDismissed = isLimitOrderTabBadgeDismissed, @@ -782,45 +777,6 @@ class TradeFragment : BaseFragment() { toToken = tempToToken } } - - private suspend fun preloadTradeTitleSymbols() { - initialSwapToSymbol = resolveInitialToTokenSymbol(isLimit = false) - initialLimitToSymbol = resolveInitialToTokenSymbol(isLimit = true) - } - - private suspend fun resolveInitialToTokenSymbol(isLimit: Boolean): String? { - val output = requireArguments().getString(ARGS_OUTPUT) - if (!output.isNullOrBlank()) { - return findTokenSymbolByAssetId(output) - } - - val lastSelectedPairJson = defaultSharedPreferences.getString(getPreferenceKey(isLimit), null) - val lastSelectedPair: List? = lastSelectedPairJson?.let { - val type = object : TypeToken>() {}.type - GsonHelper.customGson.fromJson(it, type) - } - val lastToSymbol = lastSelectedPair?.getOrNull(1)?.symbol?.trim() - if (!lastToSymbol.isNullOrEmpty()) { - return lastToSymbol - } - - val input = requireArguments().getString(ARGS_INPUT) - if (input.isNullOrBlank()) { - return null - } - val fallbackOutputAssetId = if (input in Constants.usdIds) XIN_ASSET_ID else USDT_ASSET_ETH_ID - return findTokenSymbolByAssetId(fallbackOutputAssetId) - } - - private suspend fun findTokenSymbolByAssetId(assetId: String): String? { - return if (inMixin()) { - swapViewModel.findToken(assetId)?.symbol?.trim() - } else { - val currentWalletId = walletId ?: return null - swapViewModel.web3TokenItemById(currentWalletId, assetId)?.symbol?.trim() - }?.takeIf { it.isNotEmpty() } - } - private suspend fun refreshStocks(chainIds: List?) { val chainIdSet: Set? = chainIds?.toSet()?.takeIf { it.isNotEmpty() } requestRouteAPI( @@ -1049,7 +1005,5 @@ class TradeFragment : BaseFragment() { stocks = emptyList() tokenItems = null web3tokens = null - initialSwapToSymbol = null - initialLimitToSymbol = null } } 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 f9a84c4c2d..3716cb08a7 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 @@ -76,8 +76,6 @@ fun TradePage( swapTo: SwapToken?, limitFrom: SwapToken?, limitTo: SwapToken?, - initialSwapToSymbol: String?, - initialLimitToSymbol: String?, inMixin: Boolean, orderBadge: Boolean, isLimitOrderTabBadgeDismissed: Boolean, @@ -215,13 +213,6 @@ fun TradePage( initialPage = initialTabIndex.coerceIn(0, (tabCount - 1).coerceAtLeast(0)), pageCount = { tabCount }, ) - val currentTradeTitle = when (pagerState.currentPage) { - 0 -> swapTo.toTradeTitleOrNull(context.getString(R.string.Trade)) - ?: initialSwapToSymbol.toTradeTitleOrNull(context.getString(R.string.Trade)) - 1 -> limitTo.toTradeTitleOrNull(context.getString(R.string.Trade)) - ?: initialLimitToSymbol.toTradeTitleOrNull(context.getString(R.string.Trade)) - else -> null - } ?: stringResource(id = R.string.Trade) // When SwapContent requests switching to Limit tab, animate to it LaunchedEffect(switchToLimitRequested.value) { @@ -263,7 +254,7 @@ fun TradePage( } ) { PageScaffold( - title = currentTradeTitle, + title = stringResource(id = R.string.Trade), subtitle = { val text = if (walletId == null) { stringResource(id = R.string.Privacy_Wallet) @@ -450,13 +441,3 @@ fun checkBalance( } ?: return null return inputValue <= balanceValue } - -private fun SwapToken?.toTradeTitleOrNull(tradeLabel: String): String? { - return this?.symbol.toTradeTitleOrNull(tradeLabel) -} - -private fun String?.toTradeTitleOrNull(tradeLabel: String): String? { - val symbol = this?.trim().orEmpty() - if (symbol.isEmpty()) return null - return "$tradeLabel $symbol" -} From 4f6e17b802ecc765e959f9667a77545768ff6e94 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 19 Mar 2026 10:25:55 +0800 Subject: [PATCH 098/105] Market leverage --- .../trade/perps/PerpsMarketListAdapter.kt | 1 + app/src/main/res/layout/item_market_list.xml | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt index e5abedb8e0..3fdb66904a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt @@ -51,6 +51,7 @@ class PerpsMarketListAdapter( val fiatSymbol = Fiats.getSymbol() iconIv.loadImage(market.iconUrl, R.drawable.ic_avatar_place_holder) symbolTv.text = market.tokenSymbol + leverageTv.text = root.context.getString(R.string.Perpetual_Leverage_Format, market.leverage) val formattedVolume = try { BigDecimal(market.volume).multiply(fiatRate).numberFormatCompact() diff --git a/app/src/main/res/layout/item_market_list.xml b/app/src/main/res/layout/item_market_list.xml index 35e4304eb4..117c03984c 100644 --- a/app/src/main/res/layout/item_market_list.xml +++ b/app/src/main/res/layout/item_market_list.xml @@ -19,19 +19,32 @@ + + From 305c5b4416aa577f4a8bc5e356b50ada8ab8797f Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 19 Mar 2026 10:36:09 +0800 Subject: [PATCH 099/105] Refine perps market titles and chart refresh --- .../android/ui/home/web3/trade/CandleChart.kt | 60 +++++++++++++------ .../trade/perps/AllPerpsMarketsFragment.kt | 2 +- .../web3/trade/perps/PerpsMarketDetailPage.kt | 37 ++++++++---- .../home/web3/trade/perps/PerpsMarketItem.kt | 2 +- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index e604c3b0eb..bb18c97328 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -48,7 +48,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.google.protobuf.Mixin +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import one.mixin.android.Constants import one.mixin.android.api.response.perps.CandleItem import one.mixin.android.api.response.perps.CandleView @@ -63,32 +67,45 @@ import java.math.BigDecimal import kotlin.math.max import kotlin.math.min +private const val CANDLE_REFRESH_INTERVAL_MS = 30_000L + @Composable fun CandleChart( marketId: String, - timeFrame: String + timeFrame: String, + marketPrice: String? = null, ) { val context = LocalContext.current val viewModel = hiltViewModel() + val lifecycleOwner = LocalLifecycleOwner.current var candles by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } - LaunchedEffect(marketId, timeFrame) { + LaunchedEffect(marketId, timeFrame, lifecycleOwner) { + candles = emptyList() isLoading = true errorMessage = null - viewModel.loadCandles( - marketId = marketId, - timeFrame = timeFrame, - onSuccess = { data -> - candles = data - isLoading = false - }, - onError = { error -> - errorMessage = error - isLoading = false + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + viewModel.loadCandles( + marketId = marketId, + timeFrame = timeFrame, + onSuccess = { data -> + candles = data + errorMessage = null + isLoading = false + }, + onError = { error -> + if (candles.isEmpty()) { + errorMessage = error + isLoading = false + } + } + ) + delay(CANDLE_REFRESH_INTERVAL_MS) } - ) + } } Box( @@ -119,14 +136,22 @@ fun CandleChart( ) } else -> { - ScrollableCandleChart(candles = candles, context = context) + ScrollableCandleChart( + candles = candles, + context = context, + marketPrice = marketPrice?.toBigDecimalOrNull() + ) } } } } @Composable -private fun ScrollableCandleChart(candles: List, context: android.content.Context) { +private fun ScrollableCandleChart( + candles: List, + context: android.content.Context, + marketPrice: BigDecimal?, +) { val candleView = candles.firstOrNull() ?: return val items = candleView.items if (items.isEmpty()) return @@ -156,7 +181,7 @@ private fun ScrollableCandleChart(candles: List, context: android.co ((x - chartStartPaddingPx) / candleStepPx).toInt().coerceIn(0, items.lastIndex) } val selectedItem = selectedIndex?.let { index -> items.getOrNull(index) } - val latestPrice = items.lastOrNull()?.close?.toBigDecimalOrNull() + val latestPrice = marketPrice ?: items.lastOrNull()?.close?.toBigDecimalOrNull() val axisPanelWidth = 52.dp Row(modifier = Modifier.fillMaxSize()) { @@ -185,6 +210,7 @@ private fun ScrollableCandleChart(candles: List, context: android.co item.high.toBigDecimalOrNull()?.let { prices.add(it) } item.low.toBigDecimalOrNull()?.let { prices.add(it) } } + latestPrice?.let { prices.add(it) } val maxPrice = prices.maxOrNull() ?: BigDecimal.ZERO val minPrice = prices.minOrNull() ?: BigDecimal.ZERO val midPrice = (maxPrice + minPrice) / BigDecimal(2) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt index 53b52764ac..c03b21029c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt @@ -113,7 +113,7 @@ private fun AllMarketsPage( } PageScaffold( - title = stringResource(R.string.All_Markets), + title = stringResource(R.string.Perpetual_Markets), verticalScrollable = false, pop = pop ) { 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 ec6238f15b..ba73e19e3f 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 @@ -47,7 +47,12 @@ 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.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.flowOf import one.mixin.android.Constants import one.mixin.android.R @@ -70,6 +75,7 @@ import one.mixin.android.vo.Fiats import java.math.BigDecimal private const val CLOSED_POSITION_PREVIEW_LIMIT = 100 +private const val MARKET_REFRESH_INTERVAL_MS = 30_000L @Composable fun PerpsMarketDetailPage( @@ -81,6 +87,7 @@ fun PerpsMarketDetailPage( ) { val context = LocalContext.current val viewModel = hiltViewModel() + val lifecycleOwner = LocalLifecycleOwner.current var market by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var selectedTimeFrame by remember { mutableIntStateOf(0) } @@ -117,17 +124,24 @@ fun PerpsMarketDetailPage( val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed - LaunchedEffect(marketId) { - viewModel.loadMarketDetail( - marketId = marketId, - onSuccess = { data -> - market = data - isLoading = false - }, - onError = { - isLoading = false + LaunchedEffect(marketId, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + viewModel.loadMarketDetail( + marketId = marketId, + onSuccess = { data -> + market = data + isLoading = false + }, + onError = { + if (market == null) { + isLoading = false + } + } + ) + delay(MARKET_REFRESH_INTERVAL_MS) } - ) + } } LaunchedEffect(walletId, openPositions.size) { @@ -563,7 +577,8 @@ private fun MarketDetailCard( ) { CandleChart( marketId = marketId, - timeFrame = timeFrameValues[selectedTimeFrame] + timeFrame = timeFrameValues[selectedTimeFrame], + marketPrice = market.markPrice ) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt index 51b64d2ab1..2ff33ea667 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt @@ -100,7 +100,7 @@ fun PerpsMarketItem( ) { Text( text = market.tokenSymbol, - fontSize = 14.sp, + fontSize = 16.sp, color = MixinAppTheme.colors.textPrimary, ) Spacer(modifier = Modifier.width(6.dp)) From 6bc4d93952d43ad628f4a46e6d12cfa1b175a490 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 19 Mar 2026 10:59:41 +0800 Subject: [PATCH 100/105] Keep perps markets ordered by rowid --- .../java/one/mixin/android/db/perps/PerpsMarketDao.kt | 9 ++++++++- .../ui/home/web3/trade/perps/PerpetualViewModel.kt | 11 ++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt index f19fd0c178..699ea498b0 100644 --- a/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import one.mixin.android.api.response.perps.PerpsMarket import one.mixin.android.db.BaseDao @@ -15,7 +16,13 @@ interface PerpsMarketDao : BaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(markets: List) - @Query("SELECT * FROM markets") + @Transaction + suspend fun replaceAll(markets: List) { + deleteAll() + insertAll(markets) + } + + @Query("SELECT * FROM markets ORDER BY rowid ASC") suspend fun getAllMarkets(): List @Query("SELECT * FROM markets WHERE market_id = :marketId") 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 b310e72a2b..4df3abeb39 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 @@ -100,12 +100,13 @@ class PerpetualViewModel @Inject constructor( val data = response.data if (response.isSuccess && data != null) { Timber.d("Perps markets loaded: ${data.size} markets") - - withContext(Dispatchers.IO) { - perpsMarketDao.insertAll(data) + + val orderedMarkets = withContext(Dispatchers.IO) { + perpsMarketDao.replaceAll(data) + perpsMarketDao.getAllMarkets() } - - onSuccess(data) + + onSuccess(orderedMarkets) } else { val error = "Failed to load markets: ${response.errorDescription}" Timber.e(error) From d026edda3adb0dc4fd9278e38105937b4d26c910 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 19 Mar 2026 12:05:01 +0800 Subject: [PATCH 101/105] Update strings --- .../ui/home/web3/trade/SwapSlippagePage.kt | 2 +- .../web3/trade/TotalPositionValueAdapter.kt | 2 +- .../trade/perps/AllPerpsMarketsFragment.kt | 2 +- .../web3/trade/perps/AllPositionsFragment.kt | 6 +-- .../home/web3/trade/perps/OpenPositionPage.kt | 4 +- .../home/web3/trade/perps/PerpetualContent.kt | 14 ++--- .../web3/trade/perps/PerpetualGuidePage.kt | 8 +-- .../web3/trade/perps/PerpsMarketDetailPage.kt | 6 +-- .../web3/trade/perps/PositionDetailPage.kt | 4 +- .../one/mixin/android/util/ErrorHandler.kt | 2 +- .../res/layout/layout_empty_transaction.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 43 ++++++++------- app/src/main/res/values/strings.xml | 53 +++++++++---------- 13 files changed, 73 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapSlippagePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapSlippagePage.kt index 21d6a65509..1f24934351 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapSlippagePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SwapSlippagePage.kt @@ -172,7 +172,7 @@ private fun Custom( horizontalAlignment = Alignment.Start, ) { Text( - text = context.getString(R.string.slippage_custom), + text = context.getString(R.string.Custom), style = TextStyle( fontSize = 18.sp, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt index d5a009bc91..4920e9582c 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -37,7 +37,7 @@ class TotalPositionValueAdapter( fun submitTitle(@StringRes titleResId: Int) { this.titleResId = titleResId - this.isClosed = (titleResId == R.string.Realized_PnL) + this.isClosed = (titleResId == R.string.Total_Realized_PnL) notifyItemChanged(0) } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt index c03b21029c..8cfdd3d416 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt @@ -113,7 +113,7 @@ private fun AllMarketsPage( } PageScaffold( - title = stringResource(R.string.Perpetual_Markets), + title = stringResource(R.string.perps_markets), verticalScrollable = false, pop = pop ) { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index 8d129e8f0d..1f60daa753 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -122,7 +122,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions binding.progressBar.isVisible = false openPositionAdapter.submitList(pagedList) val isEmpty = pagedList.isEmpty() - binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Positions) + binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Position) binding.emptyView.helpAction.isVisible = isEmpty binding.emptyView.root.isVisible = isEmpty } @@ -131,7 +131,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions binding.progressBar.isVisible = false closedPositionAdapter.submitList(pagedList) val isEmpty = pagedList.isEmpty() - binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Closed_Positions) + binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Activity) binding.emptyView.helpAction.isVisible = false binding.emptyView.root.isVisible = isEmpty } @@ -189,7 +189,7 @@ class AllPositionsFragment : BaseFragment(R.layout.fragment_all_closed_positions loadOpenPositions() } else { binding.titleView.setSubTitle(getString(R.string.perps_activity), "") - totalValueAdapter.submitTitle(R.string.Realized_PnL) + totalValueAdapter.submitTitle(R.string.Total_Realized_PnL) binding.positionsRv.adapter = ConcatAdapter(totalValueAdapter, closedPositionAdapter) loadClosedPositions() } 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 e483786dc8..4bad63b7df 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 @@ -414,7 +414,7 @@ fun OpenPositionPage( } val displayText = when (lev) { - -1 -> stringResource(R.string.slippage_custom) + -1 -> stringResource(R.string.Custom) maxLeverage.takeIf { it > 1 } -> stringResource(R.string.Max) else -> "${lev}x" } @@ -491,7 +491,7 @@ fun OpenPositionPage( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row (verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(R.string.Position_Size), + text = stringResource(R.string.position_size), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist ) 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 2ad8855049..3fbbb64ff9 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 @@ -222,7 +222,7 @@ fun PerpetualContent( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(R.string.Open_Positions, openPositionsCount), + text = stringResource(R.string.positions_count, openPositionsCount), fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) @@ -247,7 +247,7 @@ fun PerpetualContent( ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.No_Positions), + text = stringResource(R.string.No_Position), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, ) @@ -265,7 +265,7 @@ fun PerpetualContent( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.How_Perps_Works), + text = stringResource(R.string.how_perps_works), fontSize = 14.sp, color = MixinAppTheme.colors.accent, ) @@ -312,7 +312,7 @@ fun PerpetualContent( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = stringResource(R.string.Perpetual_Markets), + text = stringResource(R.string.perps_markets), fontSize = 14.sp, color = MixinAppTheme.colors.textPrimary, ) @@ -416,7 +416,7 @@ fun PerpetualContent( ) Spacer(modifier = Modifier.height(12.dp)) Text( - text = stringResource(R.string.No_Closed_Positions), + text = stringResource(R.string.No_Activity), fontSize = 14.sp, color = MixinAppTheme.colors.textAssist, ) @@ -454,13 +454,13 @@ fun PerpetualContent( Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.How_Perps_Works), + text = stringResource(R.string.how_perps_works), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary ) Text( - text = stringResource(R.string.Learn_How_To_Trade_Perps), + text = stringResource(R.string.learn_how_to_trade_perps), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 83203168f2..a9afccce35 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -85,11 +85,11 @@ fun PerpetualGuidePage( ) { val coroutineScope = rememberCoroutineScope() val tabs = listOf( - stringResource(R.string.Perpetual_Guide_Overview), + stringResource(R.string.Brief_Introduction), stringResource(R.string.Long), stringResource(R.string.Short), stringResource(R.string.Leverage), - stringResource(R.string.Position_Size) + stringResource(R.string.position_size) ) val safeInitialTab = initialTab.coerceIn(0, tabs.lastIndex) var selectedTab by remember(safeInitialTab) { mutableIntStateOf(safeInitialTab) } @@ -180,7 +180,7 @@ fun PerpetualGuidePage( @Composable private fun OverviewContent() { GuideSection( - title = stringResource(R.string.Perpetual_Guide_Overview_Title), + title = stringResource(R.string.Overview), content = stringResource(R.string.perps_intro_overview) ) } @@ -407,7 +407,7 @@ private fun PositionContent() { value = "${formatGuideInt(investment)} USDT" ), GuideRowData( - label = stringResource(R.string.Position_Size), + label = stringResource(R.string.position_size), value = orderValueText ) ), 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 ba73e19e3f..1d6e7afe86 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 @@ -417,13 +417,13 @@ private fun MarketInfoCard( Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.How_Perps_Works), + text = stringResource(R.string.how_perps_works), fontSize = 14.sp, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary ) Text( - text = stringResource(R.string.Learn_How_To_Trade_Perps), + text = stringResource(R.string.learn_how_to_trade_perps), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) @@ -754,7 +754,7 @@ private fun OpenPositionCard( Column { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringResource(R.string.Position_Size), + text = stringResource(R.string.position_size), fontSize = 12.sp, color = MixinAppTheme.colors.textAssist ) 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 8ec9c85e14..4f29e7ac41 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 @@ -234,7 +234,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.Position_Size).uppercase(), + label = stringResource(R.string.position_size).uppercase(), value = "${String.format("%f", absQuantity)} ${position.tokenSymbol ?: ""}", subtitle = formatFiat(orderValue) ) @@ -564,7 +564,7 @@ fun PositionDetailPage( Spacer(modifier = Modifier.height(20.dp)) PositionDetailItem( - label = stringResource(R.string.Position_Size).uppercase(), + label = stringResource(R.string.position_size).uppercase(), value = "${String.format("%f", absQuantity)} ${positionHistory.tokenSymbol ?: ""}", subtitle = formatFiat(orderValue) ) 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 f89e0bbd13..cc02357c06 100644 --- a/app/src/main/java/one/mixin/android/util/ErrorHandler.kt +++ b/app/src/main/java/one/mixin/android/util/ErrorHandler.kt @@ -334,7 +334,7 @@ fun Context.getMixinErrorStringByCode( getString(R.string.error_perps_order_value_too_small) } ErrorHandler.PERPS_MARKET_ALREADY_HAS_ACTIVE_POSITION -> { - getString(R.string.error_perps_market_already_has_active_position) + getString(R.string.error_already_had_open_position) } ErrorHandler.UNSUPPORTED_WATCH_ADDRESS -> { getString(R.string.error_watch_address_not_supported) diff --git a/app/src/main/res/layout/layout_empty_transaction.xml b/app/src/main/res/layout/layout_empty_transaction.xml index b7536ff76b..0be7fb938e 100644 --- a/app/src/main/res/layout/layout_empty_transaction.xml +++ b/app/src/main/res/layout/layout_empty_transaction.xml @@ -37,7 +37,7 @@ android:layout_below="@id/wallet_transactions_empty" android:layout_centerHorizontal="true" android:layout_marginTop="@dimen/margin16" - android:text="@string/How_Perps_Works" + android:text="@string/how_perps_works" android:textColor="@color/colorAccent" android:visibility="gone" /> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6d1d4daf75..6e46026c89 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -471,7 +471,7 @@ 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 错误 10650: 仓位规模太小,请调整后重试。 - 错误 10651:当前市场已开仓。 + 错误 10651:当前已有持仓 当前市场已开仓。 错误 20114:验证码已过期 错误 20113:验证码错误 @@ -1602,7 +1602,7 @@ 滑点 自动 根据不同币种推荐合适的滑点,帮助你交易成功 - 自定义 + 自定义 将按照您设置的滑点执行交易 您的交易可能会抢先交易并导致不利的交易 滑点不可大于 50% 且不可小于 0.1% @@ -2229,7 +2229,7 @@ Safe 金库、观察钱包和隐藏的资产不计入统计 共管的 Safe 金库不计入统计 交易说明 - 简介 + 简介 <%1$s %1$s> 永续合约允许您使用杠杆交易加密货币,从而放大您的潜在利润(和损失)。您可以做多(押注价格上涨)或做空(押注价格下跌),而无需拥有标的资产。 @@ -2237,7 +2237,7 @@ 做空意味着您预期价格会下跌。如果价格下跌,您就会获利。如果价格上涨,您就会亏损。您的盈亏会被杠杆倍数放大。 杠杆允许您用更少的资金控制更大的仓位。例如,使用 10 倍杠杆,1%% 的价格变动会导致 10%% 的盈亏。杠杆越高,风险越大。 您可以随时平仓以实现盈亏。平仓价格基于当前市场价格。请务必监控您的仓位以避免爆仓。 - 具体说明 + 具体说明 Mixin 永续合约是一种以数字资产结算的衍生品交易方式,支持做多和做空,无到期日。通过杠杆机制,交易者可以放大仓位,把握价格上涨或下跌带来的交易机会。 产品特点: 无到期日,可长期持仓 @@ -2250,10 +2250,10 @@ 请合理控制杠杆倍数与仓位规模,谨慎交易 举例说明 开仓交易 - 价格上涨 %1$s%% → 盈利 %2$s%% (+%3$s) - 价格下跌 %1$s%% → 盈利 %2$s%% (+%3$s) - 价格下跌 %1$s%% → 亏损 -%2$s %3$s - 价格上涨 %1$s%% → 亏损 -%2$s %3$s + 价格上涨 %1$s → 盈利 %2$s(%3$s) + 价格下跌 %1$s → 盈利 %2$s(%3$s) + 价格下跌 %1$s → 亏损 %2$s + 价格上涨 %1$s → 亏损 %2$s 投入资金 场景一:价格上涨 场景二:价格下跌 @@ -2263,7 +2263,6 @@ 上涨幅度 下跌幅度 盈亏 - 具体说明 做多是指在预期价格上涨时建立仓位,价格上涨可获得盈利,价格下跌则产生亏损。 做空是指在预期价格下跌时建立仓位,价格下跌可获得盈利,价格上涨则产生亏损。 产生盈利 @@ -2287,21 +2286,21 @@ 杠杆 选择代币 选择杠杆 - 仓位规模 + 仓位规模 清算价格 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) 做多 做空 - 方向 + 方向 开仓价格 价格 - 最新价格 + 最新价格 平仓价格 数量 持仓详情 持仓概要 持仓信息 - 已实现盈亏 + 已实现盈亏 价格变化 时间信息 开仓时间 @@ -2317,25 +2316,25 @@ 平仓做多 平仓做空 预估清算价格 - 持仓总价值 + 仓位总价值 保证金 %1$s %2$s %1$d倍 - 持仓(%1$d) + 持仓(%d) 持仓 持仓 历史记录 - 暂无持仓 - 暂无历史记录 + 暂无仓位 + 暂无历史记录 盈亏 - 开仓方向 + 开仓方向 暂无行情 查看更多 加载中... - 成交量 %1$s + 交易量:%1$s 开仓: $%1$s → 平仓: $%2$s - 永续合约如何运作? - 了解如何交易永续合约 + 永续合约如何运作? + 了解如何交易永续合约 24小时成交量 未平仓量 资金费率 @@ -2344,7 +2343,7 @@ 未检测到sim卡。 选择一个国家或地区 匿名号码 - 市场 + 市场 忘记 PIN 切换账号 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd9785e3cd..636d1b9571 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -486,7 +486,7 @@ 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 10650: Position size is too small. - ERROR 10651: The current market already has an open position. + ERROR 10651: You already had a open position The current market already has an open position. ERROR 20114: Expired phone verification code ERROR 20113: Invalid phone verification code @@ -1654,7 +1654,7 @@ Slippage Auto Recommends the right slippage for different currencies to help you trade successfully - Custom + Custom Trades will be executed according to the slippage you set Your transaction may be frontrun and result in an unfavourable trade Slippage cannot be greater than 50% and cannot be less than 0.1% @@ -2295,7 +2295,7 @@ Safes, watch wallets, and hidden assets are excluded from the total Co-managed safes are excluded from the total Trading Guide - Overview + Overview <%1$s %1$s> Perpetual contracts allow you to trade cryptocurrency with leverage, enabling you to amplify your potential profits (and losses). You can go long (bet on price increase) or short (bet on price decrease) without owning the underlying asset. @@ -2303,13 +2303,13 @@ Going short means you expect the price to decrease. If the price goes down, you profit. If it goes up, you lose. Your profit/loss is multiplied by your leverage. Leverage allows you to control a larger position with less capital. For example, with 10x leverage, a 1%% price move results in a 10%% profit or loss. Higher leverage means higher risk. You can close your position at any time to realize your profit or loss. The closing price is based on the current market price. Make sure to monitor your positions to avoid liquidation. - Instructions + Introduction Mixin perpetual contracts are derivative trading instruments settled in digital assets, supporting long and short positions with no expiration date. Through leverage, traders can amplify positions to capture trading opportunities from price movements. - Product Features - No expiration date, can hold positions long-term + Product Features: + No expiration date, allowing positions to be held long-term Support long/short, bidirectional trading Up to %1$dx leverage supported - Support isolated margin mode for flexible risk control + Isolated margin mode for flexible risk control Risk Warning Leverage trading can amplify gains but also losses. When margin is insufficient, positions may be forcibly liquidated. @@ -2329,17 +2329,16 @@ Upward Change Downward Change P&L - Detailed Description Going long means establishing a position when expecting price to rise. Profit when price rises, loss when price falls. Going short means establishing a position when expecting price to fall. Profit when price falls, loss when price rises. Profit Loss - P&L Rules + PnL Rules: Leverage is used to amplify trading size, controlling larger contract positions with less margin. P&L Impact - Leverage amplifies both gains and losses. + Leverage amplifies both profits and losses Higher leverage means greater P&L fluctuation with price movements. - Please choose leverage reasonably, with high leverage, even small price movements can lead to significant losses. + Please choose your leverage carefully. With high leverage, even small price fluctuations may result in significant losses. The position size of the current contract is calculated by "Margin × Leverage", representing the asset size controlled in this transaction. Usage Determines the market exposure size of this transaction. @@ -2353,12 +2352,12 @@ Leverage Select Token Select Leverage - Size + Size Liquidation Price Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) Long Short - Side + Direction Entry Price Mark Price Latest Price @@ -2367,27 +2366,27 @@ Position Details Position Summary Position Info - Realized PnL + Total Realized PnL Price Change Time Info Open Time Close Time Mixin Futures Wallet not found - How perps works? - Learn how to trade perps + How perps works? + Learn how to trade perps 24H VOLUME Open Interest Funding Rate - Confirm Open Position + Confirm Opening Open Position Success + Close Position Confirm Close Position Close Position Success - Close Position - Open Long - Open Short - Close Long - Close Short + Opened Long + Opened Short + Closed Long + Closed Short Estimated Liquidation Price Total Position Value %1$s %2$s @@ -2395,14 +2394,14 @@ $%1$f %1$s$%2$f %1$s(%2$.2f%%) - Positions(%1$d) + Positions(%d) Positions Position Activity - No Positions - No Activities + No Position + No Activity PNL - Direction + Direction No Markets View More Loading... @@ -2413,7 +2412,7 @@ No SIM card detected. Choose a Country or Region Anonymous Number - Markets + Markets Forgot PIN Switch Account From 12722b0be8ecbf759aade4b3c6adfbb469bd2413 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 19 Mar 2026 12:42:25 +0800 Subject: [PATCH 102/105] Fix buy payment web title --- .../mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt | 3 ++- app/src/main/java/one/mixin/android/ui/web/WebActivity.kt | 4 +++- app/src/main/java/one/mixin/android/ui/web/WebFragment.kt | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt index 3603848713..3e9c4249e1 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/CalculateFragment.kt @@ -416,7 +416,8 @@ class CalculateFragment : BaseFragment(R.layout.fragment_calculate) { WebActivity.show( requireActivity(), response.data?.url ?: "", - null + null, + fixedTitle = getString(R.string.Buy_asset, asset.symbol) ) } else { ErrorHandler.handleMixinError(response.errorCode, response.errorDescription) diff --git a/app/src/main/java/one/mixin/android/ui/web/WebActivity.kt b/app/src/main/java/one/mixin/android/ui/web/WebActivity.kt index 2eab997887..55dfcbfe8c 100644 --- a/app/src/main/java/one/mixin/android/ui/web/WebActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/web/WebActivity.kt @@ -45,7 +45,8 @@ class WebActivity : BaseActivity() { conversationId: String?, app: App? = null, appCard: AppCardData? = null, - saveName: Boolean? = null + saveName: Boolean? = null, + fixedTitle: String? = null ) { context.startActivity( Intent(context, WebActivity::class.java).apply { @@ -65,6 +66,7 @@ class WebActivity : BaseActivity() { putParcelable(WebFragment.ARGS_APP, app) putParcelable(WebFragment.ARGS_APP_CARD, appCard) putBoolean(WebFragment.ARGS_SAVE_NAME, saveName ?: false) + putString(WebFragment.ARGS_FIXED_TITLE, fixedTitle) }, ) }, diff --git a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt index 5d7d3bc462..aa163d16c9 100644 --- a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt @@ -206,6 +206,7 @@ class WebFragment : BaseFragment() { const val ARGS_SHAREABLE = "args_shareable" const val ARGS_SAVE_NAME = "args_save_name" const val ARGS_INJECTABLE = "args_injectable" + const val ARGS_FIXED_TITLE = "args_fixed_title" const val themeColorScript = """ (function() { @@ -244,6 +245,9 @@ class WebFragment : BaseFragment() { private val injectable: Boolean by lazy { requireArguments().getBoolean(ARGS_INJECTABLE, true) } + private val fixedTitle: String? by lazy { + requireArguments().getString(ARGS_FIXED_TITLE) + } private var currentUrl: String? = null private var currentTitle: String? = null @@ -649,7 +653,7 @@ class WebFragment : BaseFragment() { ) { super.onReceivedTitle(view, title) if (!isBot()) { - _binding?.titleTv?.text = title + _binding?.titleTv?.text = fixedTitle ?: title if (once) { once = false val saveName = requireArguments().getBoolean(ARGS_SAVE_NAME, false) @@ -906,6 +910,7 @@ class WebFragment : BaseFragment() { } } app?.name?.let { binding.titleTv.text = it } + fixedTitle?.let { binding.titleTv.text = it } app?.iconUrl?.let { binding.iconIv.isVisible = true binding.iconIv.loadImage(it) From ddd9c44c75e3362e72710d34902cd9224e816ecd Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 16 Mar 2026 16:32:08 +0800 Subject: [PATCH 103/105] Trade spot guide, refine wallet onboarding guides and spot trade limit pricing --- ...SpotTradeGuideBottomSheetDialogFragment.kt | 80 ++ .../ui/home/web3/trade/SpotTradeGuidePage.kt | 882 ++++++++++++++++++ .../ui/home/web3/trade/TradeFragment.kt | 26 +- .../android/ui/home/web3/trade/TradePage.kt | 9 +- .../AddWalletBottomSheetDialogFragment.kt | 36 +- .../wallet/components/AssetDashboardScreen.kt | 211 ++++- .../wallet/components/WalletCategoryFilter.kt | 6 +- .../mixin_import_safety_preview_0.png | Bin 0 -> 2151 bytes .../mixin_import_safety_preview_1.png | Bin 0 -> 1670 bytes .../mixin_import_safety_preview_2.png | Bin 0 -> 1407 bytes .../mixin_import_safety_preview_3.png | Bin 0 -> 1587 bytes .../mixin_import_safety_preview_4.png | Bin 0 -> 1518 bytes .../mixin_import_safety_preview_5.png | Bin 0 -> 1156 bytes .../mixin_import_safety_preview_6.png | Bin 0 -> 1527 bytes .../mixin_import_safety_preview_7.png | Bin 0 -> 3287 bytes .../main/res/drawable/ic_add_wallet_freee.xml | 25 + .../main/res/drawable/ic_add_watch_wallet.xml | 34 + app/src/main/res/drawable/ic_dot_assist.xml | 5 + .../main/res/drawable/ic_import_wallet.xml | 31 + .../fragment_add_wallet_bottom_sheet.xml | 574 ++++++++---- app/src/main/res/values-zh-rCN/strings.xml | 57 +- app/src/main/res/values/strings.xml | 49 +- 22 files changed, 1823 insertions(+), 202 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuideBottomSheetDialogFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuidePage.kt create mode 100644 app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_0.png create mode 100644 app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_1.png create mode 100644 app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_2.png create mode 100644 app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_3.png create mode 100644 app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_4.png create mode 100644 app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_5.png create mode 100644 app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_6.png create mode 100644 app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_7.png create mode 100644 app/src/main/res/drawable/ic_add_wallet_freee.xml create mode 100644 app/src/main/res/drawable/ic_add_watch_wallet.xml create mode 100644 app/src/main/res/drawable/ic_dot_assist.xml create mode 100644 app/src/main/res/drawable/ic_import_wallet.xml diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuideBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuideBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..604ef1c179 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuideBottomSheetDialogFragment.kt @@ -0,0 +1,80 @@ +package one.mixin.android.ui.home.web3.trade + +import android.annotation.SuppressLint +import android.app.Dialog +import android.os.Bundle +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.screenHeight +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.util.SystemUIManager + +@AndroidEntryPoint +class SpotTradeGuideBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + + companion object { + const val TAG = "SpotTradeGuideBottomSheetDialogFragment" + private const val ARGS_INITIAL_TAB = "args_initial_tab" + + const val TAB_OVERVIEW = 0 + const val TAB_SWAP = 1 + const val TAB_LIMIT = 2 + + fun newInstance(initialTab: Int = TAB_OVERVIEW) = SpotTradeGuideBottomSheetDialogFragment().apply { + arguments = Bundle().apply { + putInt(ARGS_INITIAL_TAB, initialTab) + } + } + } + + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + + @Composable + override fun ComposeContent() { + val initialTab = arguments?.getInt(ARGS_INITIAL_TAB, TAB_OVERVIEW) ?: TAB_OVERVIEW + MixinAppTheme { + SpotTradeGuidePage( + initialTab = initialTab, + pop = { dismiss() } + ) + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + override fun showError(error: String) { + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuidePage.kt new file mode 100644 index 0000000000..73e6eac9ea --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuidePage.kt @@ -0,0 +1,882 @@ +package one.mixin.android.ui.home.web3.trade + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.draw.clip +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import one.mixin.android.Constants +import one.mixin.android.R +import one.mixin.android.compose.CoilImage +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.numberFormat8 +import one.mixin.android.extension.priceFormat +import one.mixin.android.ui.home.web3.components.OutlinedTab +import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.vo.safe.TokenItem +import one.mixin.android.widget.components.DotText +import java.math.BigDecimal +import java.math.RoundingMode + +private val LIMIT_PRICE_STEP = BigDecimal("1000") +private val LIMIT_PRICE_TEN_THOUSAND = BigDecimal("10000") + +private enum class LimitStrategy( + val titleRes: Int, +) { + BuyLow( + titleRes = R.string.Spot_Trade_Guide_Limit_Strategy_Buy_Low, + ), + SellHigh( + titleRes = R.string.Spot_Trade_Guide_Limit_Strategy_Sell_High, + ), +} + +@Composable +fun SpotTradeGuidePage( + initialTab: Int = SpotTradeGuideBottomSheetDialogFragment.TAB_OVERVIEW, + pop: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val tabs = listOf( + stringResource(R.string.Overview), + stringResource(R.string.Trade_Simple), + stringResource(R.string.Trade_Advanced), + ) + val safeInitialTab = initialTab.coerceIn(0, tabs.lastIndex) + var selectedTab by remember(safeInitialTab) { mutableIntStateOf(safeInitialTab) } + + Column( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + .background(MixinAppTheme.colors.background) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 20.dp), + ) { + Text( + text = stringResource(R.string.Trading_Guide), + fontSize = 18.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier.align(Alignment.CenterStart), + ) + Icon( + painter = painterResource(id = R.drawable.ic_circle_close), + contentDescription = stringResource(id = R.string.close), + tint = Color.Unspecified, + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable(onClick = pop), + ) + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + tabs.forEachIndexed { index, tab -> + OutlinedTab( + text = tab, + selected = selectedTab == index, + showBadge = false, + onClick = { coroutineScope.launch { selectedTab = index } } + ) + if (index < tabs.lastIndex) { + Spacer(modifier = Modifier.width(10.dp)) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + when (selectedTab) { + SpotTradeGuideBottomSheetDialogFragment.TAB_OVERVIEW -> OverviewContent() + SpotTradeGuideBottomSheetDialogFragment.TAB_SWAP -> SimpleSwapContent() + SpotTradeGuideBottomSheetDialogFragment.TAB_LIMIT -> LimitTradeContent() + } + Spacer(modifier = Modifier.height(24.dp)) + } + + Spacer(modifier = Modifier.height(20.dp)) + SpotTradeGuideBottomNavigation( + selectedTab = selectedTab, + tabs = tabs, + onSelect = { targetTab -> + coroutineScope.launch { selectedTab = targetTab } + }, + onClose = pop, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun OverviewContent() { + TradeGuideInfoCard( + title = stringResource(R.string.Perpetual_Guide_Overview_Title), + description = stringResource(R.string.Spot_Trade_Guide_Overview_Desc), + sections = listOf( + stringResource(R.string.Perpetual_Features) to listOf( + stringResource(R.string.Spot_Trade_Guide_Feature_1), + stringResource(R.string.Spot_Trade_Guide_Feature_2), + ), + stringResource(R.string.Spot_Trade_Guide_Additional_Notes) to listOf( + stringResource(R.string.Spot_Trade_Guide_Note_1), + stringResource(R.string.Spot_Trade_Guide_Note_2), + ), + ) + ) +} + +@Composable +private fun SimpleSwapContent() { + SpotTradeExampleCard(limitStrategy = null) + Spacer(modifier = Modifier.height(16.dp)) + TradeGuideInfoCard( + title = stringResource(R.string.Brief_Introduction), + description = stringResource(R.string.Spot_Trade_Guide_Swap_Desc), + sections = listOf( + stringResource(R.string.Spot_Trade_Guide_Suitable_Scenarios) to listOf( + stringResource(R.string.Spot_Trade_Guide_Swap_Scenario_1), + stringResource(R.string.Spot_Trade_Guide_Swap_Scenario_2), + stringResource(R.string.Spot_Trade_Guide_Swap_Scenario_3), + ), + stringResource(R.string.Spot_Trade_Guide_Quote_Explanation) to listOf( + stringResource(R.string.Spot_Trade_Guide_Swap_Quote_1), + stringResource(R.string.Spot_Trade_Guide_Swap_Quote_2), + ), + stringResource(R.string.Perpetual_Risk_Warning) to listOf( + stringResource(R.string.Spot_Trade_Guide_Swap_Risk), + ), + ) + ) +} + +@Composable +private fun LimitTradeContent() { + SpotTradeExampleCard(limitStrategy = LimitStrategy.BuyLow) + Spacer(modifier = Modifier.height(16.dp)) + TradeGuideInfoCard( + title = stringResource(R.string.Brief_Introduction), + description = stringResource(R.string.Spot_Trade_Guide_Limit_Desc), + sections = listOf( + stringResource(R.string.Spot_Trade_Guide_Suitable_Scenarios) to listOf( + stringResource(R.string.Spot_Trade_Guide_Limit_Scenario_1), + stringResource(R.string.Spot_Trade_Guide_Limit_Scenario_2), + stringResource(R.string.Spot_Trade_Guide_Limit_Scenario_3), + stringResource(R.string.Spot_Trade_Guide_Limit_Scenario_4), + ), + stringResource(R.string.Perpetual_Risk_Warning) to listOf( + stringResource(R.string.Spot_Trade_Guide_Limit_Risk), + ), + ) + ) +} + +@Composable +private fun SpotTradeExampleCard( + limitStrategy: LimitStrategy?, +) { + val viewModel = hiltViewModel() + val usdtToken by viewModel.assetItemFlow(Constants.AssetId.USDT_ASSET_ETH_ID).collectAsStateWithLifecycle(initialValue = null) + val btcToken by viewModel.assetItemFlow(Constants.ChainId.BITCOIN_CHAIN_ID).collectAsStateWithLifecycle(initialValue = null) + var priceRefreshFlag by remember { mutableStateOf(false) } + val marketPrice = remember(usdtToken?.priceUsd, btcToken?.priceUsd, priceRefreshFlag) { + calculateMarketPrice(usdtToken, btcToken) + } + var isPairReversed by remember(limitStrategy) { mutableStateOf(false) } + var isPriceDisplayReversed by remember(limitStrategy) { mutableStateOf(false) } + var payAmount by remember(limitStrategy) { mutableStateOf(BigDecimal("1000")) } + var strategy by remember(limitStrategy) { mutableStateOf(limitStrategy ?: LimitStrategy.BuyLow) } + var limitPriceOffset by remember(limitStrategy, strategy) { mutableStateOf(BigDecimal.ZERO) } + + val fromToken = if (isPairReversed) btcToken else usdtToken + val toToken = if (isPairReversed) usdtToken else btcToken + val amountStep = remember(isPairReversed) { + if (isPairReversed) BigDecimal("0.001") else BigDecimal("100") + } + val limitBasePrice = remember(marketPrice, strategy) { + calculateLimitBasePrice( + marketPrice = marketPrice, + strategy = strategy, + ) + } + val effectivePrice = if (limitStrategy == null) { + marketPrice + } else { + limitBasePrice.add(limitPriceOffset).max(LIMIT_PRICE_STEP) + } + val estimatedReceive = remember(payAmount, effectivePrice, isPairReversed) { + calculateReceiveAmount( + amount = payAmount, + price = effectivePrice, + reversed = isPairReversed, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.Perpetual_Example), + fontSize = 16.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(12.dp)) + if (limitStrategy != null) { + StrategyRow( + strategy = strategy, + onStrategySelected = { strategy = it }, + ) + Spacer(modifier = Modifier.height(12.dp)) + } + ExampleValueRow( + title = stringResource(R.string.Trade_Guide_Trading_Pair), + value = { + PairSwitcher( + fromToken = fromToken, + toToken = toToken, + fromFallbackSymbol = if (isPairReversed) "BTC" else "USDT", + toFallbackSymbol = if (isPairReversed) "USDT" else "BTC", + onSwitch = { + val currentFromPrice = if (isPairReversed) btcToken.safePrice() else usdtToken.safePrice() + val newFromPrice = if (isPairReversed) usdtToken.safePrice() else btcToken.safePrice() + payAmount = convertPayAmount(payAmount, currentFromPrice, newFromPrice) + isPairReversed = !isPairReversed + }, + ) + }, + ) + Spacer(modifier = Modifier.height(12.dp)) + ExampleValueRow( + title = stringResource(R.string.Trade_Guide_Pay_Amount), + value = { + AmountStepper( + amount = payAmount, + symbol = fromToken?.symbol ?: if (isPairReversed) "BTC" else "USDT", + step = amountStep, + onDecrease = { + payAmount = (payAmount - amountStep).max(amountStep) + }, + onIncrease = { + payAmount += amountStep + }, + ) + }, + ) + Spacer(modifier = Modifier.height(12.dp)) + ExampleValueRow( + title = stringResource( + if (limitStrategy == null) R.string.Trade_Guide_Exchange_Price else R.string.Trade_Guide_Order_Price + ), + value = { + if (limitStrategy == null) { + PriceSubtitle( + marketPrice = effectivePrice, + isReversed = isPriceDisplayReversed, + onSwitchDirection = { isPriceDisplayReversed = !isPriceDisplayReversed }, + onPriceExpired = { priceRefreshFlag = !priceRefreshFlag }, + ) + } else { + OrderPriceStepper( + price = effectivePrice, + symbol = usdtToken?.symbol ?: "USDT", + onDecrease = { + limitPriceOffset = (limitPriceOffset - LIMIT_PRICE_STEP) + .max(LIMIT_PRICE_STEP.subtract(limitBasePrice)) + }, + onIncrease = { + limitPriceOffset += LIMIT_PRICE_STEP + }, + ) + } + }, + ) + if (limitStrategy != null) { + Spacer(modifier = Modifier.height(12.dp)) + ExampleValueRow( + title = stringResource(R.string.Trade_Guide_Market_Price), + value = { + PriceSubtitle( + marketPrice = marketPrice, + isReversed = isPriceDisplayReversed, + onSwitchDirection = { isPriceDisplayReversed = !isPriceDisplayReversed }, + onPriceExpired = { priceRefreshFlag = !priceRefreshFlag }, + ) + }, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + ExampleValueRow( + title = stringResource(R.string.Estimated_Receive), + value = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + CoilImage( + model = toToken?.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(18.dp) + .clip(CircleShape), + ) + Text( + text = "${estimatedReceive.numberFormat8()} ${toToken?.symbol ?: if (isPairReversed) "USDT" else "BTC"}", + fontSize = 15.sp, + lineHeight = 22.sp, + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + textAlign = TextAlign.End, + ) + } + }, + ) + } +} + +@Composable +private fun StrategyRow( + strategy: LimitStrategy, + onStrategySelected: (LimitStrategy) -> Unit, +) { + ExampleValueRow( + title = stringResource(R.string.Trade_Guide_Trading_Strategy), + value = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LimitStrategy.entries.forEach { item -> + val selected = item == strategy + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background( + if (selected) MixinAppTheme.colors.accent + else MixinAppTheme.colors.backgroundWindow + ) + .clickable { onStrategySelected(item) } + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(item.titleRes), + color = if (selected) Color.White else MixinAppTheme.colors.textPrimary, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + } + } + } + }, + ) +} + +@Composable +private fun ExampleValueRow( + title: String, + subtitle: (@Composable () -> Unit)? = null, + value: @Composable () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontSize = 13.sp, + lineHeight = 18.sp, + color = MixinAppTheme.colors.textAssist, + ) + subtitle?.let { + Spacer(modifier = Modifier.height(4.dp)) + it() + } + } + Box(contentAlignment = Alignment.CenterEnd) { + value() + } + } +} + +@Composable +private fun PairSwitcher( + fromToken: TokenItem?, + toToken: TokenItem?, + fromFallbackSymbol: String, + toFallbackSymbol: String, + onSwitch: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.clickable(onClick = onSwitch), + ) { + GuideTokenBadge(token = fromToken, fallbackSymbol = fromFallbackSymbol) + Text( + text = "->", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + GuideTokenBadge(token = toToken, fallbackSymbol = toFallbackSymbol) + Icon( + painter = painterResource(id = R.drawable.ic_price_switch), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +private fun GuideTokenBadge( + token: TokenItem?, + fallbackSymbol: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + CoilImage( + model = token?.iconUrl, + placeholder = R.drawable.ic_avatar_place_holder, + modifier = Modifier + .size(20.dp) + .clip(CircleShape), + ) + Text( + text = token?.symbol ?: fallbackSymbol, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + ) + } +} + +@Composable +private fun AmountStepper( + amount: BigDecimal, + symbol: String, + step: BigDecimal, + onDecrease: () -> Unit, + onIncrease: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StepperButton( + text = "-", + enabled = amount > step, + onClick = onDecrease, + ) + Text( + text = "${amount.numberFormat8()} $symbol", + fontSize = 15.sp, + lineHeight = 22.sp, + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + ) + StepperButton( + text = "+", + enabled = true, + onClick = onIncrease, + ) + } +} + +@Composable +private fun OrderPriceStepper( + price: BigDecimal, + symbol: String, + onDecrease: () -> Unit, + onIncrease: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StepperButton( + text = "-", + enabled = price > LIMIT_PRICE_STEP, + onClick = onDecrease, + ) + Text( + text = "${price.setScale(0, RoundingMode.DOWN).numberFormat8()} $symbol", + fontSize = 15.sp, + lineHeight = 22.sp, + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + ) + StepperButton( + text = "+", + enabled = true, + onClick = onIncrease, + ) + } +} + +@Composable +private fun StepperButton( + text: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Surface( + color = if (enabled) Color.Transparent else MixinAppTheme.colors.backgroundWindow, + shape = CircleShape, + border = BorderStroke(1.dp, MixinAppTheme.colors.borderColor), + modifier = Modifier + .size(24.dp) + .clickable(enabled = enabled, onClick = onClick), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + fontSize = 14.sp, + color = if (enabled) MixinAppTheme.colors.textPrimary else MixinAppTheme.colors.textAssist, + ) + } + } +} + +@Composable +private fun PriceSubtitle( + marketPrice: BigDecimal, + isReversed: Boolean, + onSwitchDirection: () -> Unit, + onPriceExpired: () -> Unit = {}, +) { + var quoteCountDown by remember(marketPrice) { mutableFloatStateOf(0f) } + + LaunchedEffect(marketPrice) { + while (isActive) { + quoteCountDown = 0f + while (isActive && quoteCountDown < 1f) { + delay(100) + quoteCountDown += 0.01f + } + onPriceExpired() + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = if (isReversed) { + val inverted = safeDivide(BigDecimal.ONE, marketPrice) + "1 USDT ≈ ${inverted.numberFormat8()} BTC" + } else { + "1 BTC ≈ ${marketPrice.priceFormat()} USDT" + }, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + CircularProgressIndicator( + progress = quoteCountDown, + modifier = Modifier.size(12.dp), + strokeWidth = 2.dp, + color = MixinAppTheme.colors.textPrimary, + backgroundColor = MixinAppTheme.colors.textAssist, + ) + Icon( + painter = painterResource(id = R.drawable.ic_price_switch), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier + .size(16.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onSwitchDirection, + ), + ) + } +} + +@Composable +private fun TradeGuideInfoCard( + title: String, + description: String, + sections: List>>, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = description, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textPrimary, + ) + sections.forEach { (sectionTitle, items) -> + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = sectionTitle, + fontSize = 16.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(8.dp)) + items.forEach { item -> + DotText( + text = item, + modifier = Modifier.padding(vertical = 4.dp), + color = MixinAppTheme.colors.textPrimary, + ) + } + } + } +} + +@Composable +private fun SpotTradeGuideBottomNavigation( + selectedTab: Int, + tabs: List, + onSelect: (Int) -> Unit, + onClose: () -> Unit, +) { + val previousTab = (selectedTab - 1).takeIf { it >= 0 } + val nextTab = (selectedTab + 1).takeIf { it < tabs.size } + if (previousTab == null && nextTab == null) { + return + } + if (previousTab != null && nextTab == null) { + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SpotTradeGuideNavigationButton( + text = stringResource(R.string.Perpetual_Guide_Previous_Tab, tabs[previousTab]), + modifier = Modifier.weight(1f), + onClick = { onSelect(previousTab) }, + ) + SpotTradeGuideNavigationButton( + text = stringResource( + R.string.Perpetual_Guide_Next_Tab, + stringResource(R.string.Start) + ), + modifier = Modifier.weight(1f), + onClick = onClose, + ) + } + return + } + if (previousTab != null && nextTab != null) { + Row( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SpotTradeGuideNavigationButton( + text = stringResource(R.string.Perpetual_Guide_Previous_Tab, tabs[previousTab]), + modifier = Modifier.weight(1f), + onClick = { onSelect(previousTab) }, + ) + SpotTradeGuideNavigationButton( + text = stringResource(R.string.Perpetual_Guide_Next_Tab, tabs[nextTab]), + modifier = Modifier.weight(1f), + onClick = { onSelect(nextTab) }, + ) + } + return + } + val targetIndex = previousTab ?: nextTab ?: return + val buttonText = if (previousTab != null) { + stringResource(R.string.Perpetual_Guide_Previous_Tab, tabs[targetIndex]) + } else { + stringResource(R.string.Perpetual_Guide_Next_Tab, tabs[targetIndex]) + } + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + SpotTradeGuideNavigationButton( + text = buttonText, + modifier = Modifier.fillMaxWidth(0.5f), + onClick = { onSelect(targetIndex) }, + ) + } +} + +@Composable +private fun SpotTradeGuideNavigationButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Button( + modifier = modifier.height(48.dp), + onClick = onClick, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = MixinAppTheme.colors.accent, + contentColor = Color.White, + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp, + ), + ) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } +} + +private fun calculateMarketPrice( + usdtToken: TokenItem?, + btcToken: TokenItem?, +): BigDecimal { + val usdtPrice = usdtToken?.priceUsd?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: BigDecimal.ONE + val btcPrice = btcToken?.priceUsd?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: BigDecimal("95594.89") + return safeDivide(btcPrice, usdtPrice) +} + +private fun calculateLimitBasePrice( + marketPrice: BigDecimal, + strategy: LimitStrategy, +): BigDecimal { + val integerPrice = marketPrice + .max(BigDecimal.ZERO) + .setScale(0, RoundingMode.DOWN) + val tenThousandUnits = integerPrice.divideToIntegralValue(LIMIT_PRICE_TEN_THOUSAND) + val basePrice = when (strategy) { + LimitStrategy.BuyLow -> tenThousandUnits.multiply(LIMIT_PRICE_TEN_THOUSAND) + LimitStrategy.SellHigh -> { + if (integerPrice.remainder(LIMIT_PRICE_TEN_THOUSAND).compareTo(BigDecimal.ZERO) == 0) { + integerPrice + } else { + tenThousandUnits.add(BigDecimal.ONE).multiply(LIMIT_PRICE_TEN_THOUSAND) + } + } + } + return basePrice.max(LIMIT_PRICE_STEP) +} + +private fun calculateReceiveAmount( + amount: BigDecimal, + price: BigDecimal, + reversed: Boolean, +): BigDecimal { + return if (reversed) { + amount.multiply(price).setScale(8, RoundingMode.HALF_UP).stripTrailingZeros() + } else { + safeDivide(amount, price).setScale(8, RoundingMode.HALF_UP).stripTrailingZeros() + } +} + +private fun convertPayAmount( + amount: BigDecimal, + currentFromPrice: BigDecimal, + newFromPrice: BigDecimal, +): BigDecimal { + if (currentFromPrice <= BigDecimal.ZERO || newFromPrice <= BigDecimal.ZERO) { + return amount + } + return amount + .multiply(currentFromPrice) + .divide(newFromPrice, 8, RoundingMode.HALF_UP) + .stripTrailingZeros() +} + +private fun TokenItem?.safePrice(): BigDecimal { + return this?.priceUsd?.toBigDecimalOrNull()?.takeIf { it > BigDecimal.ZERO } ?: BigDecimal.ONE +} + +private fun safeDivide( + dividend: BigDecimal, + divisor: BigDecimal, +): BigDecimal { + if (divisor.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO + } + return dividend.divide(divisor, 8, RoundingMode.HALF_UP).stripTrailingZeros() +} 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 17091c54ee..0b21fafacf 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 @@ -369,10 +369,30 @@ class TradeFragment : BaseFragment() { this@apply.hideKeyboard() navTo(OrderDetailFragment.newInstance(orderId), OrderDetailFragment.TAG) }, - onShowTradingGuide = { + onShowTradingGuide = { tabIndex -> this@apply.hideKeyboard() - PerpetualGuideBottomSheetDialogFragment.newInstance() - .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + when { + walletId == null && tabIndex >= SpotTradeGuideBottomSheetDialogFragment.TAB_LIMIT -> { + PerpetualGuideBottomSheetDialogFragment.newInstance() + .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + } + tabIndex == 1 -> { + SpotTradeGuideBottomSheetDialogFragment.newInstance( + SpotTradeGuideBottomSheetDialogFragment.TAB_LIMIT + ).show(parentFragmentManager, SpotTradeGuideBottomSheetDialogFragment.TAG) + } + tabIndex == 0 -> { + SpotTradeGuideBottomSheetDialogFragment.newInstance( + SpotTradeGuideBottomSheetDialogFragment.TAB_SWAP + ).show(parentFragmentManager, SpotTradeGuideBottomSheetDialogFragment.TAG) + } + else -> { + SpotTradeGuideBottomSheetDialogFragment.newInstance( + SpotTradeGuideBottomSheetDialogFragment.TAB_OVERVIEW + ) + .show(parentFragmentManager, SpotTradeGuideBottomSheetDialogFragment.TAG) + } + } }, pop = { navigateUp(navController) 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 3716cb08a7..3873bab094 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 @@ -98,7 +98,7 @@ fun TradePage( onSwitchToLimitOrder: (String, SwapToken, SwapToken) -> Unit, pop: () -> Unit, onLimitOrderClick: (String) -> Unit, - onShowTradingGuide: () -> Unit, + onShowTradingGuide: (Int) -> Unit, onShowMarketList: (Boolean) -> Unit, onShowAllMarkets: () -> Unit, onShowAllOpenPositions: () -> Unit, @@ -195,7 +195,7 @@ fun TradePage( perpetualTabIndex = tabs.size tabs += TabItem(title = stringResource(R.string.Perpetual)) { PerpetualContent( - onShowTradingGuide = onShowTradingGuide, + onShowTradingGuide = { onShowTradingGuide(perpetualTabIndex ?: 0) }, onShowMarketList = onShowMarketList, onShowAllMarkets = onShowAllMarkets, onShowAllOpenPositions = onShowAllOpenPositions, @@ -232,7 +232,6 @@ fun TradePage( sheetBackgroundColor = MixinAppTheme.colors.background, sheetContent = { HelpBottomSheetContent( - hideGuide = perpetualTabIndex == null || pagerState.currentPage != perpetualTabIndex, onContactSupport = { coroutineScope.launch { bottomSheetState.hide() @@ -242,7 +241,7 @@ fun TradePage( onTradingGuide = { coroutineScope.launch { bottomSheetState.hide() - onShowTradingGuide() + onShowTradingGuide(pagerState.currentPage) } }, onDismiss = { @@ -396,7 +395,7 @@ fun TradePage( } if (isPerpetualTab && !isPerpetualTabBadgeDismissed) { onDismissPerpetualTabBadge() - onShowTradingGuide() + onShowTradingGuide(index) } onTabChanged(index) }, diff --git a/app/src/main/java/one/mixin/android/ui/wallet/AddWalletBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/AddWalletBottomSheetDialogFragment.kt index 0a7657a5c5..2d246e81e8 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/AddWalletBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/AddWalletBottomSheetDialogFragment.kt @@ -3,7 +3,12 @@ package one.mixin.android.ui.wallet import android.annotation.SuppressLint import android.app.Dialog import android.view.LayoutInflater +import android.view.View +import one.mixin.android.R import one.mixin.android.databinding.FragmentAddWalletBottomSheetBinding +import one.mixin.android.extension.defaultSharedPreferences +import one.mixin.android.extension.openUrl +import one.mixin.android.extension.putBoolean import one.mixin.android.ui.common.MixinBottomSheetDialogFragment import one.mixin.android.widget.BottomSheet @@ -17,6 +22,7 @@ class AddWalletBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { companion object { const val TAG = "AddWalletBottomSheetDialogFragment" + private const val PREF_FREE_TRANSFER_CARD_DISMISSED = "pref_free_transfer_card_dismissed" fun newInstance() = AddWalletBottomSheetDialogFragment() } @@ -34,6 +40,11 @@ class AddWalletBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { setCustomView(contentView) } binding.apply { + val openFreeTransferDoc = { + requireContext().openUrl(getString(R.string.url_cross_wallet_transaction_free)) + } + val preferences = requireContext().defaultSharedPreferences + rightIv.setOnClickListener { dismiss() } addWatchAddress.setOnClickListener { callback?.invoke(Action.ADD_WATCH_ADDRESS) @@ -51,7 +62,30 @@ class AddWalletBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { callback?.invoke(Action.CREATE_WALLET) dismiss() } + freeTransferCard.visibility = if (preferences.getBoolean(PREF_FREE_TRANSFER_CARD_DISMISSED, false)) { + View.GONE + } else { + View.VISIBLE + } + freeTransferCloseIv.setOnClickListener { + preferences.putBoolean(PREF_FREE_TRANSFER_CARD_DISMISSED, true) + freeTransferCard.visibility = View.GONE + } + bindOptionalView("free_transfer_card") { + openFreeTransferDoc() + } + bindOptionalView("free_transfer_learn_more") { + openFreeTransferDoc() + } } } -} + private fun bindOptionalView( + idName: String, + onClick: () -> Unit, + ) { + val id = binding.root.resources.getIdentifier(idName, "id", requireContext().packageName) + if (id == 0) return + binding.root.findViewById(id)?.setOnClickListener { onClick() } + } +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt index 876ad33672..2280d32336 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/AssetDashboardScreen.kt @@ -82,6 +82,13 @@ fun AssetDashboardScreen( val context = LocalContext.current val safeCreateGuidelineUrl: String = stringResource(R.string.safe_create_guideline_url) val safeLearnMoreUrl: String = stringResource(R.string.safe_learn_more_url) + val importWalletTitle = context.stringByName("import_wallet_title") + val importWalletDescription = context.stringByName("import_wallet_empty_description") + val importWalletGuideUrl = context.stringByName("import_wallet_guide_url") + val watchWalletDescription = context.stringByName("watch_wallet_empty_description") + val watchWalletGuideUrl = context.stringByName("watch_wallet_guide_url") + val importWalletIconRes = context.drawableByName("ic_import_wallet") + val watchWalletIconRes = context.drawableByName("ic_add_watch_wallet") val prefs = remember { context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) } val hidePrivacyWalletInfo = remember { mutableStateOf(prefs.getBoolean(KEY_HIDE_PRIVACY_WALLET_INFO, false)) } val hideCommonWalletInfo = remember { mutableStateOf(prefs.getBoolean(KEY_HIDE_COMMON_WALLET_INFO, false)) } @@ -164,17 +171,27 @@ fun AssetDashboardScreen( .align(Alignment.TopEnd) .size(8.dp) .background(color = Color.Red, shape = CircleShape) - ) + ) } } } - val hasImported = wallets.any { it.isImported() } - val hasWatch = wallets.any { it.isWatch() } + val hasImportedWallets = wallets.any { it.isImported() } + val hasWatchWallets = wallets.any { it.isWatch() } + val hasSafeWallets = wallets.any { it.category == WalletCategory.MIXIN_SAFE.value } + val filteredWallets = remember(wallets, selectedCategory) { + wallets.filter { wallet -> + shouldShowWallet( + wallet = wallet, + selectedCategory = selectedCategory, + ) + } + } + val showTotalAssetsCard = selectedCategory == null || filteredWallets.isNotEmpty() WalletCategoryFilter( selectedCategory = selectedCategory, - hasImported = hasImported, - hasWatch = hasWatch, + hasImported = true, + hasWatch = true, hasSafe = true, showSafeBadge = !hasSeenSafeCategoryBadge.value, onCategorySelected = { @@ -199,8 +216,38 @@ fun AssetDashboardScreen( .fillMaxSize() .verticalScroll(rememberScrollState()) ) { - TotalAssetsCard(selectedCategory = selectedCategory) - Spacer(modifier = Modifier.height(20.dp)) + if (showTotalAssetsCard) { + TotalAssetsCard(selectedCategory = selectedCategory) + Spacer(modifier = Modifier.height(20.dp)) + } + + if (selectedCategory == "import" && !hasImportedWallets) { + WalletEmptyGuideCard( + title = importWalletTitle, + description = importWalletDescription, + iconRes = importWalletIconRes, + primaryActionText = stringResource(R.string.Import), + onPrimaryActionClick = onAddWalletClick, + onLearnMoreClick = { + context.openUrl(importWalletGuideUrl) + }, + ) + Spacer(modifier = Modifier.height(10.dp)) + } + + if (selectedCategory == "watch" && !hasWatchWallets) { + WalletEmptyGuideCard( + title = stringResource(R.string.add_watch_address), + description = watchWalletDescription, + iconRes = watchWalletIconRes, + primaryActionText = stringResource(R.string.Add), + onPrimaryActionClick = onAddWalletClick, + onLearnMoreClick = { + context.openUrl(watchWalletGuideUrl) + }, + ) + Spacer(modifier = Modifier.height(10.dp)) + } // Privacy wallet - always show if no filter or "all" selected if (selectedCategory == null) { @@ -211,7 +258,7 @@ fun AssetDashboardScreen( Spacer(modifier = Modifier.height(10.dp)) } - if (selectedCategory == WalletCategory.MIXIN_SAFE.value && wallets.any { it.category == WalletCategory.MIXIN_SAFE.value }.not()) { + if (selectedCategory == WalletCategory.MIXIN_SAFE.value && !hasSafeWallets) { if (Session.getAccount()?.membership?.isMembership() == true) { CreateSafeCard( onCreateClick = { @@ -231,18 +278,7 @@ fun AssetDashboardScreen( Spacer(modifier = Modifier.height(10.dp)) } - wallets.forEach { wallet -> - val shouldShow = when (selectedCategory) { - null -> wallet.category != WalletCategory.MIXIN_SAFE.value && wallet.category != WalletCategory.WATCH_ADDRESS.value // Exclude safe, watching wallets when no category filter is selected - WalletCategory.MIXIN_SAFE.value -> wallet.category == WalletCategory.MIXIN_SAFE.value - WalletCategory.CLASSIC.value -> wallet.category == WalletCategory.CLASSIC.value - "import" -> wallet.isImported() - "watch" -> wallet.isWatch() - else -> true - } - - if (!shouldShow) return@forEach - + filteredWallets.forEach { wallet -> if (wallet.category == WalletCategory.MIXIN_SAFE.value) { WalletCard( name = wallet.name, @@ -298,6 +334,30 @@ fun AssetDashboardScreen( } } +private fun shouldShowWallet( + wallet: one.mixin.android.db.web3.vo.WalletItem, + selectedCategory: String?, +): Boolean { + return when (selectedCategory) { + null -> wallet.category != WalletCategory.MIXIN_SAFE.value && wallet.category != WalletCategory.WATCH_ADDRESS.value + WalletCategory.MIXIN_SAFE.value -> wallet.category == WalletCategory.MIXIN_SAFE.value + WalletCategory.CLASSIC.value -> wallet.category == WalletCategory.CLASSIC.value + "import" -> wallet.isImported() + "watch" -> wallet.isWatch() + else -> true + } +} + +private fun Context.stringByName(name: String): String { + val resourceId = resources.getIdentifier(name, "string", packageName) + return if (resourceId != 0) getString(resourceId) else "" +} + +private fun Context.drawableByName(name: String): Int? { + return resources.getIdentifier(name, "drawable", packageName) + .takeIf { it != 0 } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun WalletInfoCard( @@ -384,6 +444,115 @@ fun WalletInfoCard( } } +@Composable +fun WalletEmptyGuideCard( + title: String, + description: String, + iconRes: Int?, + primaryActionText: String, + onPrimaryActionClick: () -> Unit, + onLearnMoreClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = MixinAppTheme.colors.textPrimary, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = description, + fontSize = 14.sp, + lineHeight = 17.5.sp, + color = MixinAppTheme.colors.textMinor, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + iconRes?.let { icon -> + Image( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + } + } + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MixinAppTheme.colors.backgroundWindow, + shape = RoundedCornerShape(16.dp) + ), + ) { + Box( + modifier = Modifier + .weight(1f) + .clickable { onPrimaryActionClick() } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = primaryActionText, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.accent, + modifier = Modifier + .padding(6.dp) + .clip(RoundedCornerShape(bottomStart = 16.dp, topStart = 16.dp)) + ) + } + Spacer( + modifier = Modifier + .width(2.dp) + .height(24.dp) + .background(Color.Black.copy(alpha = 0.05f)) + .align(Alignment.CenterVertically) + ) + + Box( + modifier = Modifier + .weight(1f) + .clickable { onLearnMoreClick() } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.Learn_More), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MixinAppTheme.colors.textPrimary, + modifier = Modifier + .padding(6.dp) + .clip(RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp)) + ) + + Icon( + painter = painterResource(id = R.drawable.ic_arrow_top_right_small), + contentDescription = null, + tint = MixinAppTheme.colors.backgroundDark, + modifier = Modifier + .size(8.dp) + .align(Alignment.TopEnd) + ) + } + } + } +} + @Composable fun PrivacyWalletInfo( onLearnMoreClick: () -> Unit, @@ -773,4 +942,4 @@ fun CardPreview() { Spacer(modifier = Modifier.height(8.dp)) UpgradeSafeCard({}, {}) } -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/components/WalletCategoryFilter.kt b/app/src/main/java/one/mixin/android/ui/wallet/components/WalletCategoryFilter.kt index 116241e003..4ada5b9019 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/components/WalletCategoryFilter.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/components/WalletCategoryFilter.kt @@ -28,9 +28,6 @@ fun WalletCategoryFilter( if (hasAll) { tabs.add(WalletCategoryTab(category = null, textResId = R.string.All, showBadge = false)) } - if (hasSafe) { - tabs.add(WalletCategoryTab(category = WalletCategory.MIXIN_SAFE.value, textResId = R.string.Wallet_Safe, showBadge = showSafeBadge)) - } if (hasCreated) { tabs.add(WalletCategoryTab(category = WalletCategory.CLASSIC.value, textResId = R.string.Wallet_Created, showBadge = false)) } @@ -40,6 +37,9 @@ fun WalletCategoryFilter( if (hasWatch) { tabs.add(WalletCategoryTab(category = "watch", textResId = R.string.Wallet_Watching, showBadge = false)) } + if (hasSafe) { + tabs.add(WalletCategoryTab(category = WalletCategory.MIXIN_SAFE.value, textResId = R.string.Wallet_Safe, showBadge = showSafeBadge)) + } val selectedIndex: Int = tabs.indexOfFirst { tab: WalletCategoryTab -> tab.category == selectedCategory }.let { index: Int -> if (index >= 0) index else 0 } diff --git a/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_0.png b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_0.png new file mode 100644 index 0000000000000000000000000000000000000000..7ea990e2431ec8a88e6396f11983b314d0b443f9 GIT binary patch literal 2151 zcmcJQ=_3;i9LJ|zIkGUfNRx_Iqn_nh06^ql|5yJjf&cV>H?oY#h8hqrDL$Yi-XSN(Q9g{9 z6f#IfyDPu*#jTp=L6u=KA`u9Ar@(E58xNt5|9$z`LQw# zJ1NmI!&dv2r6k+^C>8#`#%_1)lb_S&bMf94k)fsq)Tgc%m>9bit)9B(W9-bNogp{) z+@+ge0RW*RR}hySd?vr=vM{4YK$Ae7SYrIWsanz6G{*D@Jt)5&iR;o^U90W^CrRa$bZ@pK81Uf}2CSPQ=i7S%~d(_2Oi0 z#@EzFPGZN^?i|Dfxyp@-#V*XoNsX#ES8WtmOruB#%4AWVUFd*a;bjfv~k3pPEf!Ksw^lm;~J5{e|8=C?Ujk|YhmBY zPu1q_jM5~|+G;Akka@^iOoin31z&}nn{vcxrM~*XJ?SY|C1$PcpM84KZ|({@WBUTG zOimYEqyaL_yz%-oHI#MD%@yAek3~R=G^h+z!zmk)wgd1quISDo(&$6QN|*A~KZRlA zFA);`mQA$Kz6Bq$K+EgG;Jc?pojcebpH|Pu3>9%AN7Dv8o!bS|lPNStZ>WKtla&(J zs?EOtTR}XPgOacFd~S5=tiwmN1fX#!iF zR5xkUjc$yq8$?=V?^~HSWn^WO0 zhD6{Wi@tZz!}}cMIV{L=WqgUd5tcW*bOX)tY0C1VOZoq9>8k0?V0N(^uxXxyCt1DS z?u*f|czc1ErQd>IB?PEh>+LCTXD%%~N=lc40uv1*Et;DB>U~L*ib79?fnrO04kNTu zf`XB;`M7M&!blH(*C^-|w?)ncyylq@rtj`db?)EpQrnIt$+r=18Q z1MR%1VCv)5X!kZ|%G*HlPul%2#(GamH(hT${`B6S;)W_En2?d3)f*cV!8`9{l5;k8 z2EJjxwnDN^3=I1nKbt)no2efTNPqKtu5P{HA^qU_)h7m#NzClw%TpK6@SruhTIzPs z15wc#PUVyz(-RFu-2~luc-K}YPQ#8kdkvIW!jrC1th)$X?aBVKdUpqM!eeRjnMzq^ zQ**O*V4}id$!prvFuN^Yc11_4n!l7ZgjqFGvZ;CUXxLN?LX*oKmrxhJz}wohmv z!Zmune)mi0@*l_!%@)!bwmQM^p~~T+wnu)&$62xg3oY<_3CsSY+FjG!$8sbLt>|e^ z%-C~-rg~IaXkQAobjpA`Z;<@pR@ha&`m-7KC{lRCx@tH*z{=o9UQc+V%OKE3mM!UG zZrdX{90KaNm>||KIq8H$>aO2PT0v@)Jbh&Y;B_%e8t0_nE{iLX@u*Q)ckUe9Qg01Vj$9PLeffdc}Uc<#BD9 z#qmvXXk?B-PS&lX{8^Q@*DczHW{trq7_BWe8O1umQE;>+J^ZPSo@(^z z(qfrzQ#*6#6RLb^sd$wU^B0y|+U}}4g0gjq1Ky<|3$O+;xt|WU0_905bylr7JDkeW zKL!uVr+|Z>8GDY(5|04QlPntFBhXrCzA<1ZwlGBe#;yjZ@nw3OLY6XpCf!@H?d5yg!D zkZ|%V2bZ^*3R*atywb8@w(k9E0ciLENX}oYEGASH^p;9(GXUvbh1CpQN~JJ9@LhZ`Z}78zcv?Ek z?czoV>tiY5;PUgHkvIOJvfzYGrtp3O}@S1<+2%tJ5x=(~5)<52E$5rdWA%KCiA zJN=$6FN>nT>Wx+(p?DWvk6HJKkqDB2#aZfz-L4IR`n{yGH!F0wh3jFrr$gRqC|;waCf0ua|0^ce J2#T>=>_3P^;JE+* literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_1.png b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_1.png new file mode 100644 index 0000000000000000000000000000000000000000..0209924fb00fa7d17d28885ad8ba585a41869221 GIT binary patch literal 1670 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_3.png b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_3.png new file mode 100644 index 0000000000000000000000000000000000000000..b45f81652841a00cb3c22e3a51e2a939f3231fae GIT binary patch literal 1587 zcmZXUc{CJy6vux`Su)|_k%?w3V`MBtNY+PY%-;g2H;1pjq^Ru)hd1zVUv_2?2faZT|NkBFP$SeSLK8yp>Y@kX7@D89j1rQXVkOE}*JRHdKyB5LjuR#3; zP`U>c2LZeh?BxHX3PAalG@xP)L~Vc=|Ir4ZPzj*?F8(MUK&^n?9DrB^$S*+6oNnd9 zPdE{r?Onje`j#~g+elJu3C$JWn9E>TrCNtOQy=JW+(998d_ClcyXsCWEsdSqwN5Kh zTOMsa9_S#>G~L;jb2-l5Salow*`wm*c;AP0H^TK$(_IgC%&9E_Aee#2>~{@j%;pEV z&@Cj=MlJKNtDt1t4JXij(okF(!XiE@7sBI}vL~hTA)AZXR>yU(CitgTbkLW{!Q#$H zX;GqOnd1-nzN@|PkCpE%*7a&^V#e&M4t)~V{g++ho*-^SX$V%?bl^C8RcTCKo2Q^_ zDQucVZN1$d9#0CP3CZ@)Q~bT-GY%B=%~}?p&y=&pN{x+Ab?=$&{c~BC9HOOF;5E4I zE{>LD!m(dNpq5$ixkQ1;rGZGi}{?fzM_bP8IMoJAE|q92EMw5 zo`7yGi-c(j3Y~tM9FywtI8>;S`T2R6RRiaUn#rMIVqksdWOv?ex9oalS_f|cpVW@} zfU>!zNw~58-keCzA*H8F&f=PVA!|51LwXAKrKPj%gVdBSO?R(Ci=A@mIknP(J8rND{-i7Qw}5O?obmcu6Hdn$NT` z6?%N7@?hOs$I3ht$6C4svePu2#j?Z$d`RvTlcpLwu9kzX5bcd$y3dvX*2R*D&=SFx z@*{e9N|86|WmXRpU)UX}(5I()C8p+3?dzku#?wc%bwtr2kAN<mur5BWR4hDb;5ajApWV2*hL?C7q^u8y=dB{ zNG`|q>gZjMAfJJ8?B}ezZEV6xrc^=Kd5gf-kE@Nn29!wZD>v#C_I*@UV@$RU-K&n7 z{>*PDTu4%K(OQS6kN&S9co~WNhtfh2hXD*;BETX@7R%gsZ{q1`)LwrvIeJ zo4qdt;@JwXg&y@pS(nL;<(Lv_!VT)N!u~hDsBMt$!kQiJ3;;7Ih*+&Wx~Aw92L$_U__I)q=_qolsP9&j z6EKFQG&N+!n?do=U<3+&c`?4$s>xmrm?kqtU)A};HSz@! L)*jPf>7V>JZwa5A literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_4.png b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_4.png new file mode 100644 index 0000000000000000000000000000000000000000..d7a92a3b55b632c848d0813100ca8750d311d042 GIT binary patch literal 1518 zcmZvce>~H99LK-=!Ini^&6;1EP=v5{;nI)M7SnFZ=u{gsqVlVgIXPl-!l=U;xztM5 zj}>DyB9j(HR70CQ9!Nqx67r)+>voUs`s40ikH_cpetn*g*X#Y~Cu=v|Q(w=`QX6=7!0O0{}l)X(n2s8{MGy`pyl64e}Dfs z4~N75H?IwRR~LmseQjwugTa76AW$e&OR}@Gc|4xhBqk<$dU^^30!wX(9RtE-ERjm2OvTeohlsj1Ocq*AF;scdU&tFErDt*vco zY3bi_si}#@V%68zYd2_jmX(#cySp2O9CFf@$#K4P2B6e|t@`MS zTqIg(5ZkNvIZoE|aD8pL8ezDN!RSzPL1r>{=e464jYjjd#7M*OrD-%X8qMdWvF=s{ zCz!F+Au8}cnBM^a&^=VLyWgRK33Xp$2??9}LJmnYx3PWq$+)PvociI;9E^Es-47+7 z0Wb7nIqNj;FD@ZxZ?x&7aIWX8KkCVAuTT|CmZZI(C!oU}AAmwZ^3k)RvD z(i(1x_+*1JYE*3pqX|SX(cmy8VIiG%qey2zPvW@T>hz#@gkp^X}et& zY#vuq#Juv#(Z3PzSWz>rZh`mOsX?F+LWD8@K1X(^A#Qf=m^>x+pf4t@trsrIXNCc( zSr(jl>rtV-4xt5aFd^8{C(Uk1w7NuEanz6sU8!4@-!!g?LMz)bj z1BQIX-jfXt{eSScXKa%@uW`|x^SJorFmRQPNir^|Q$E(pJ8-|7^_ZbliKKXwsd;UTeaC&yGGoI}lBT z4ri{H2Z+KGg1f#8qpZGlBKaJBb#4aofc`S584%y=AA?79d^e^$Kn$fDMfq#i{o>Q^eHy`AV=|VZj+J=s9Zo%qGF&3>M;Zgfa}|{92nD zfBr_8ZLi+s>WIpm*V1C~=CB(i?OtE6 zD9>=55D~M#p|HNwXN!{{u+JJti1v(3pIqLVThGCX_GFlK+?>4z3sc>6G0siXYuNjf z&xm;IK&sy~r=ni+*u{?RhdkH>9q``G69WkiY>8b9M}2 z0WbERdXE~C6kVvR^45JcFDxC|G_g!TTdCd!MK?bF@=L!UH;f&G+FkbZVx$M5zv8aO zqS^1q$+@g7_f_4JvEjJ0OB$~iYufz=k3}&9Q@}=IrDg;8Eixs}Ngrm*^A}u)U0)vj cj~;(m-Ni095ViE})_zt1l|m<1c(Bv{1gcGoRsaA1 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_5.png b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_5.png new file mode 100644 index 0000000000000000000000000000000000000000..bd449738e3213febf3a0e06de04e471b03f7b993 GIT binary patch literal 1156 zcmeAS@N?(olHy`uVBq!ia0vp^IUvlz3?z5#SpFYKr3LtexB_ViU<3mo8^QpRa5fVY z6NC#90dgTC5E7yu$Og(nNVqbf6huwlL!+HQQN@xVzhDLyjypfqzPvvtbKSM;k(KbC z@c3hMUu|68!ad1Y$G@g1gS&Bq2Ll818c!F;kc@k8uHEiiY#_jL!N_7;W5IDnl_x>} z|4)`~G@W>YZ%xX359LBR$D&}~vgW&a+i$-u+a15y=Y#X4cEz)g*Tb?vgohk4B z$KLr`cF;bjofTJV%@iyeH)X1>TvgQ&d`;c;)VG-m8-zV?i(Y&r*`=qR$>Job*l=$i z|Buy&ZLbNkxUpQ=z{s-gO3s7zi;vlFc=O==1YWbNDctotmmFF)=gdR@*~aoW3X=0p z_Ha&hKE?4miEpEjV^?g&u~kjFpA?Sjw5`q1T)%;_>tI%n>+VYow+_YYD7yslt=|2( z%j4OH>zi2KWk;8#a7{7!aJ`tN<jM9_l}4U42n2jY03s zgY$cPj+f_pElcp2slc#WY0r!NFl*o3z9p_4w%wKMR+{{qQnV`QLyJJ(o`k+-PuP!Y zO6cuTY;nvwbJ5DhyK}bAH0Gui5j$9gbtKb@#kE^L#JxQB>hrY5g8B6dvzAI9fBfme zob@VOIG&!o_x63+&*y=@2d(THH(YGt{*+cZqgk`%@`lzKizF@H2fHpv?QJMZi&y^X zyK&BLY2_u%Q)leoClcRh^T>4qJBvy|Nw3M72R!w+jHP2;6BW45Gn|?qz{0{3c>eeP zC)4<1roJ(|?(*%K+Fz#({-ZTBC%Q;8%$uh4_R0BMH6_1%4lhnM&AaTx9v>rrgxf&K zeyW)AoET-Me!FvgQHM(+xikY_d2+pZ@z{WO^4Y~)i+32yZpjw-zC715pl+-FtU2Wh z;@8!-UcF>*>)Vm_U{kAi&PTa@LBW}vWv7nYiCuRKIdDPo?8WB}uCsTYx&B1?GJC=D z-ozk5rWeXVRyqE&7!$qzv#Ra7cj94zMV&zZ9#*3dbI#cuR~7cIisbk6y*_)yd`5#m4U znwp}+BL!hCY)p>)Ka)MRo_92^Bo^SVwzYCJ^pV^TU3wfOpq9H~IMl-3jc)PgfJmgv5u!cbVjN%;cMF*u?wRJu!7nQIY+5 zNN~8gh81aQmvVd8?5(^PC0n_szk=mD*1j;QXklur@wpg6(KS_RWsg6nskF(v&(SMI zUSbP{tN~UKNofjfqRuLZ9s~&yNz7jp&zTlAFx1H&hFsb_RojJ79*e&2;RO()DFX6| zA3a^wsB=MaX-WE>)F*R9hv|U+GAK}G2h>Ayq0gQb^;S(^%a{+iqONdBU2{E2S2N`G zu7sbx#^&s!?(R)-^U5fxbw_ksL$wtMFX4|9E-8>uUWTp~U{0ZEM47Dx^ix^e6*a=c zZu>;3E3;*XB0|uZvCIHL8JmGIEdsBlqqob{3j%av+IE%CASfbs&7myaped1)nx-TJ zgL=J#FhV9_jw3R<8u8l0>k_dE2R=1|)2WQfH4usnJG3J}ZydbxPcH`MPbReaZD;r}k zIFG}eF1i#ZCYVdq^t6~ZNw1_QGI+T|98(t_wJ?R4u~fY~!13BD55*_xa*R$JY;F2! zBS$k?eJR|%Ll6B}WM2D<-I<-P@@B#3KJ>9Ab5XK4(}E}I%*+DG(WA);6^eZ-JYj-ymc?4t>#}_sNx^AR$(AU%q4`ZXLiTJU!2(PIqoog0 z#I|bG!Q5?18S1J;pQL2lk{-CS-JxiW-N*;gSiaX=Duw-TgOH&!&AIN87=Jdp0IUX>ag&1UmwRdt=V|iaQ)7=t0%T22Q#FC z<*JD$sve^Lqv$HPh7P<@_Bnq_`WH1Sh8z2#g@eW8dsGAFhPxT+{S@&ID$7c*eC7*H zb@DP8=&Strw>tF}jKL?bh7A@HIc%c`h`#1#Z5A=WhyN}UM*5HP>62A7@{mvV?6DvB NjWMxAl^Wrq{sjV(sGa}- literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_7.png b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_7.png new file mode 100644 index 0000000000000000000000000000000000000000..cabfe9440588668f8da1a5d4c6b01de7352a3fdf GIT binary patch literal 3287 zcmb7{_dgVlAIDG0*=Mgaa#mJW(ji;+$hwR!3TG#0-i5fRoRPhCNb(_vQ|3|Gdxfl1 zM##!KOF?zUBLINW%1BS!CX{M3hr$28K5y5>A*nfM)R^z;japA!VM(F7 zEQsogK6Mz~rQH-ROMfkF66+J1Z$+m0W$|{M3zw_iFaioA~I zVcdr6PlH2UOE%6$Z%haR`H8>)q3P!y0jwj;DJ_M}4P-Z-qdtLdja^3gTzu$o98ZCd zF|W#>E6pl@!bfOF&r2-}UqLdl72IWPQ=+?4*;13_aYn{qUZs{^DyEm8Zc3 z_%ZJ-o4#~pSX#JkgmagR_-a@!Z)G0fg=XNIE2vtKrfNuT8U@;i49lJ^f8W#&4`b*$41( z58kEfDu_c&Ny%7HKyEYV8DFwjd`dNWc6sSXNwto_C~-f7;Z+$uQ*j{0$F<5ry``Ha3gaKT1T4ZGIFmrohgo-|I| zHWAYw?D}VUy?Tby8r{e}J(5|LwlWddF(>qKKJAp96@7n8mAyK%TN-haSRlw>1-1(8 z7ryDs?yPzONWSl!m+l5pi94Oi5W4bcSJj0q2?PH0ihw`gY_xWbHN-gAhVlL;P;9WF-i{(kG;i5 z^w!H&UBuSr5qk{7jZDp%YzX_Rh7OqVA2A5jZvc<=G)XMl>O zm$mWXx+hTIFy@;gb&*U`Ub~-66Vxfx^z(Jco`Mg0?8A5-_v6!d_r;4-MA*iA=NR$) zoVgIiaS{>$!^&uHRI6I-Z)i=%hijVkV<9WwV`I?y;6PR7q0fOw?sD_?+>!WzV>~eE zNstrw+kyT49lQ`zFHve2$?7K}AYa~_{ZvRr8w&N{{KwZJJIoYZwQ!e5`2$Sck899w zd2;r}xBw5yTROW5eYZ&`FVP$poa)2LUDTrN@m;^5Xo(9=R2bfrNfUCO+lyq$W+Z@) z;j1`UrN97i;H{GmQS)jYIha{2}oh={5 z5OSoqmT?wj!qCSnYh6Ipi2b@wLw2bFs=5^Gz1{PL@P@9}QXJ@J1`e%|>=czOX0(GVpDB<6 zpWMK~f%83k9z1LQ^_BNlKSt&8u68Kpsm1-tPkQB}_4KKK>VabNJoPrttzl*?b5hu# zpq8r7@JCt2-2j=&PDhBf9bDZka=yvm*^Tax6G{x?BhA*RWZh^1f|i-AKmhV1+4U*NQ69iO*V9OO(43xK)N^D8^VqSc~Y0s zN{kk~pqHv=2E$)c0N@AP#6&NRmSx+Wbl>8-Ob!yNX@tEJz7^ADe+G6I2j#Po2aMon zv+QeH0%2QIx zFFeq<$Km&zW7OD$uX)3rCL9?ZoiH<8c6u&^I?BXCO#i632y-J;Eo0A z+*p#@v%H)7C$lRy?$=P$s8x2MQ~~PE1nBXP> z8RqLE2W*it*>QI}eGbAB*7d`1DhZ_+X6Teno!F-pq4*2ofYk1JB7%mP7`UCg@-b67 znVD(s9OG+HsdMtILA$;kO`6;$_%R6CRAV5e7}Gl@n$X{TyPRxCWm zep#&W!11&ng0Xb)^gH;2WGLqGUixh(tl3s;@>%6;#0X98AUW+^u@^C=d#=Xj&vgED zSor!xD1EOQ@nxZOuZ3o3ImIgX=IWZRz55;v68<^QZ@X>ynf&swR^$rnVArrV_7X8X zyoCli_((16hKp%BYwGCgiQHS5(gfPGOn3WBJHf4E|G@BDi;^Ht_AABQ-sS9}V#(|; zeGgRy4`;-m0wgB3WaRa*^=Oou7)O+?P7?w?MJ<( z@;fF%mcK2hW?p>lH5gI42=-CmWNRk#FSH56WDuY>h0~{>$AiENTE2Wf*A8Xx;2Y@X zy@QH@ox~R;9GnCU5=xhkU~q084MY=mcEFLDlK?@dA_1U+C8^{q`zhb#P(!?mrAwpVW| zJg4x9K%8#p(W*ZSLA!->x3cZH?&zAFZ-6vE#iHs}3GfEU=!NGpdaMey+i!%3PULLm zs6X-^oG=l{b$*z3*rw-4QgloR{6R$2hx^yJa{FNS>#VOUeQmC~?|24E{7>op|Ib~K aR7<_O6+_RTc>L`h03&^Ky-FSD=>GvJ+Ecjz literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/ic_add_wallet_freee.xml b/app/src/main/res/drawable/ic_add_wallet_freee.xml new file mode 100644 index 0000000000..55ee7f67e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_wallet_freee.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_add_watch_wallet.xml b/app/src/main/res/drawable/ic_add_watch_wallet.xml new file mode 100644 index 0000000000..133faa646e --- /dev/null +++ b/app/src/main/res/drawable/ic_add_watch_wallet.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_dot_assist.xml b/app/src/main/res/drawable/ic_dot_assist.xml new file mode 100644 index 0000000000..aad828fb18 --- /dev/null +++ b/app/src/main/res/drawable/ic_dot_assist.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_import_wallet.xml b/app/src/main/res/drawable/ic_import_wallet.xml new file mode 100644 index 0000000000..a84ebf7212 --- /dev/null +++ b/app/src/main/res/drawable/ic_import_wallet.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_add_wallet_bottom_sheet.xml b/app/src/main/res/layout/fragment_add_wallet_bottom_sheet.xml index c2e6c8cc87..b08bef4856 100644 --- a/app/src/main/res/layout/fragment_add_wallet_bottom_sheet.xml +++ b/app/src/main/res/layout/fragment_add_wallet_bottom_sheet.xml @@ -1,12 +1,11 @@ + android:background="@drawable/bg_upper_round"> - + android:layout_height="wrap_content" + android:fillViewport="true" + android:overScrollMode="ifContentScrolls"> - + - + - + - + - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6e46026c89..bf18533338 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -358,7 +358,7 @@ 普通钱包转账到其他普通钱包、隐私钱包、Mixin Safe 金库或联系人。 手续费按月、按实际消耗的手续费币种和金额汇总返还到您的隐私钱包,阅读文档了解更多。 了解更多 - https://support.mixin.one/zh/article/cross-wallet-transaction-fee-event-1k9b47q + https://support.mixin.one/zh/article/mixin-10yozl6/ 桌面版已登入。 请升级 Mixin 桌面端至最新版! 检测到一个 Mixin 二维码,点击识别 @@ -634,6 +634,17 @@ 上次备份 稍后 了解更多 + 导入钱包 + 使用私钥或 12/24 个助记词导入。支持 Bitcoin、Solana、Ethereum 以及其他 EVM 网络。 + https://support.mixin.one/zh/article/5aac5l2v5a85ywl5yqp6k6w6kn6zkx5yyf77yf-eri489/ + 无需私钥即可观察地址。可快速查看余额、交易记录和收款活动通知,享受原生流畅体验。 + https://support.mixin.one/zh/article/5aac5l2v5re75yqg6kec5af5zyw5z2a77yf-r3pitk/ + 钱包之间免费提现 + 限时享受钱包之间免费提现。 + 导入到 Mixin 安全吗? + Mixin 是开源的、可复现构建的,并经过独立审计。 + 您的数据会被安全加密,并仅存储在您的本地设备上。 + Mixin 绝不会访问您的隐私信息,也不会与第三方共享。 忙线未接听 对方忙 链接桌面端 @@ -2250,13 +2261,21 @@ 请合理控制杠杆倍数与仓位规模,谨慎交易 举例说明 开仓交易 - 价格上涨 %1$s → 盈利 %2$s(%3$s) - 价格下跌 %1$s → 盈利 %2$s(%3$s) - 价格下跌 %1$s → 亏损 %2$s - 价格上涨 %1$s → 亏损 %2$s 投入资金 - 场景一:价格上涨 - 场景二:价格下跌 + 具体说明 + Mixin 交易支持简单闪兑和专业模式,简单易用,支持多链、多币种,并聚合多个 DEX 与 CEX 的深度。 + 产品特点 + 最优聚合:支持 Uniswap、1inch、Jupiter、MixPay、BigONE 等 DEX 和 CEX。 + 跨链交易:支持 Bitcoin、Ethereum、Solana 等多链资产交易。 + 补充说明 + 交易手续费主要由交易所和网络提现手续费构成,Mixin 不额外收取交易手续费。 + 交易返佣主要来自交易所的手续费分润。 + 风险提示 + 举例说明 + 价格上涨 %1$s%% → 盈利 %2$s%% (+%3$s) + 价格下跌 %1$s%% → 盈利 %2$s%% (+%3$s) + 价格下跌 %1$s%% → 亏损 -%2$s %3$s + 价格上涨 %1$s%% → 亏损 -%2$s %3$s 场景%1$d:%2$s 价格上涨 价格下跌 @@ -2281,6 +2300,30 @@ 抵扣浮动亏损 当亏损接近已投入资金时,可能被系统强制平仓。 价格剧烈波动可能会快速消耗投入资金。 + 交易币种 + 支付金额 + 兑换价格 + 交易策略 + 成交价格 + 市场价格 + 适合场景 + 报价说明 + 闪兑是一种操作简单、按当前最优市场价格立即成交的兑换方式。 + 希望立即成交,追求操作效率和确定性。 + 兑换主流币或流动性充足的交易对,市价成交通常不会产生明显价差。 + 新手友好,操作简单、直观。 + 交易报价为 DEX 和 CEX 聚合最优价。 + 交易报价已包含交易所手续费和网络提现手续费。 + 市场剧烈波动时,实际成交价格可能与预期有差异。 + 低价买入\n高价卖出 + 低价买入 + 高价卖出 + 专业模式支持限价挂单,这是一种由用户自行设定成交价格,在市场价格达到预期时才会成交的交易方式。 + 有明确交易策略,例如区间交易、低买高卖等。 + 对成交价格敏感,希望严格按照指定价格或更优价格成交。 + 进行大额交易,希望通过挂单方式逐步成交,避免对市场造成冲击或产生明显滑点。 + 不急于立即成交,希望在价格到达预期区间后,由系统自动撮合完成交易。 + 市场剧烈波动时,可能会出现快速跳过挂单价格,导致无法成交。 永续合约 开仓 杠杆 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 636d1b9571..b6e3a9b7d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,7 +372,7 @@ Regular wallet transfer to other regular wallets, private wallets, Mixin Safe, or contacts. The handling fee will be refunded to your private wallet on a monthly basis, based on the actual currency and amount of the handling fee consumed. Read the documentation for more information. more information - https://support.mixin.one/en/article/cross-wallet-transaction-fee-event-1k9b47q + https://support.mixin.one/en/article/campaign-free-transactions-between-mixin-wallets-kozded/ You are signed in on desktop Please upgrade Mixin Desktop to the latest version. Detected a Mixin QR code, tap to recognize @@ -657,6 +657,17 @@ Last Backup Later Learn More + Import Wallet + Import with a private key or 12/24-word recovery phrase. Supports Bitcoin, Solana, Ethereum, and other EVM networks. + https://support.mixin.one/en/article/how-to-import-mnemonic-phrases-tjujvv/ + Watch addresses without private keys. View balances, transactions, and receive activity notifications with a fast native experience. + https://support.mixin.one/en/article/how-to-add-a-watch-only-address-17ifo8w/ + Free transfers between wallets + Free transfers between wallets, for a limited time. + Is it safe to import into Mixin? + Mixin is open source, reproducible, and independently audited. + Your data is securely encrypted and stored locally on your device. + Mixin never accesses your private information or shares it with third parties. Line busy Line busy Link Desktop @@ -2316,13 +2327,21 @@ Please control leverage and position size reasonably and trade cautiously. Example Perpetual + Instructions + Mixin Trade supports both simple swaps and a professional limit-order mode. It is easy to use, supports multiple chains and assets, and aggregates liquidity across DEXs and CEXs. + Product Features + Best aggregation: Supports Uniswap, 1inch, Jupiter, MixPay, BigONE, and more across DEXs and CEXs. + Cross-chain trading: Supports assets across Bitcoin, Ethereum, Solana, and other networks. + Additional Notes + Trading costs mainly come from exchange fees and network withdrawal fees. Mixin does not charge additional trading fees. + Trading rebates mainly come from fee-sharing arrangements with exchanges. + Risk Warning + Example Price up %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Profit %2$s%% (+%3$s) Price down %1$s%% → Loss -%2$s %3$s Price up %1$s%% → Loss -%2$s %3$s Amount - Scenario 1: Price Rise - Scenario 2: Price Fall Scenario %1$d: %2$s Price Rise Price Fall @@ -2347,6 +2366,30 @@ Offset floating losses. When losses approach the invested capital, a forced liquidation by the system may occur. Severe price volatility may rapidly consume investment. + Trading Pair + Pay Amount + Exchange Price + Trading Strategy + Order Price + Market Price + Suitable Scenarios + Quote Details + Simple Swap is an easy-to-use trading mode that executes immediately at the current best available market price. + Ideal when you want immediate execution with a straightforward flow and predictable completion. + Well suited for mainstream assets or trading pairs with strong liquidity, where market execution usually keeps price differences limited. + Beginner-friendly, with a simple and intuitive experience. + Quotes are aggregated from the best prices available across DEXs and CEXs. + Quotes already include exchange fees and network withdrawal fees. + During sharp market movements, the final execution price may differ from the expected quote. + Buy low\nSell high + Buy low + Sell high + Professional mode supports limit orders, letting you set your own execution price so the order is matched only when the market reaches your target price or better. + Useful when you have a clear trading strategy, such as range trading or buying low and selling high. + Suitable when you are sensitive to execution price and want the order filled strictly at your target or better. + Helpful for larger orders when you want to place orders gradually and avoid large market impact or obvious slippage. + Suitable when you do not need immediate execution and prefer the system to match automatically once the price enters your target range. + During sharp market movements, the market may jump over your limit price quickly and the order may remain unfilled. Perpetual Open Position Leverage From 2d91adda9c85fbda2b6ee16e2bcb6d115cf64db1 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 19 Mar 2026 16:27:51 +0800 Subject: [PATCH 104/105] Add pinch zoom to candle chart --- .../android/ui/home/web3/trade/CandleChart.kt | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index bb18c97328..6309c54245 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -3,6 +3,7 @@ package one.mixin.android.ui.home.web3.trade import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -27,6 +28,7 @@ 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -64,10 +66,16 @@ import org.threeten.bp.ZoneId import org.threeten.bp.ZonedDateTime import org.threeten.bp.format.DateTimeFormatter import java.math.BigDecimal +import kotlinx.coroutines.launch +import kotlin.math.abs import kotlin.math.max import kotlin.math.min +import kotlin.math.roundToInt private const val CANDLE_REFRESH_INTERVAL_MS = 30_000L +private const val DEFAULT_CANDLE_SCALE = 1f +private const val MIN_CANDLE_SCALE = 0.5f +private const val MAX_CANDLE_SCALE = 3f @Composable fun CandleChart( @@ -156,16 +164,24 @@ private fun ScrollableCandleChart( val items = candleView.items if (items.isEmpty()) return - val candleWidth = 6.dp - val spacing = 2.dp + val baseCandleWidth = 6.dp + val baseSpacing = 2.dp val density = LocalDensity.current val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() var touchXOnChart by remember { mutableStateOf(null) } var isTouching by remember { mutableStateOf(false) } + var isPinching by remember { mutableStateOf(false) } + var candleScale by remember(items.size) { mutableStateOf(DEFAULT_CANDLE_SCALE) } + + val candleWidth = baseCandleWidth * candleScale + val spacing = baseSpacing * candleScale val candleStepPx = with(density) { (candleWidth + spacing).toPx() } val candleWidthPx = with(density) { candleWidth.toPx() } + val baseCandleWidthPx = with(density) { baseCandleWidth.toPx() } + val baseSpacingPx = with(density) { baseSpacing.toPx() } val chartStartPaddingPx = with(density) { 8.dp.toPx() } val totalChartWidthPx = with(density) { (8.dp + (candleWidth * items.size) + (spacing * (items.size - 1).coerceAtLeast(0))).toPx() @@ -231,7 +247,49 @@ private fun ScrollableCandleChart( modifier = Modifier .fillMaxSize() .padding(end = axisPanelWidth) - .pointerInput(items.size, scrollState.value) { + .pointerInput( + items.size, + viewportWidthPx, + chartStartPaddingPx, + baseCandleWidthPx, + baseSpacingPx, + ) { + detectTransformGestures( + panZoomLock = true, + onGesture = { centroid, pan, zoom, _ -> + if (abs(zoom - 1f) < 0.0001f && abs(pan.x) < 0.0001f) { + return@detectTransformGestures + } + + isPinching = true + isTouching = false + touchXOnChart = null + + val oldScale = candleScale + val newScale = (oldScale * zoom).coerceIn(MIN_CANDLE_SCALE, MAX_CANDLE_SCALE) + val oldStepPx = (baseCandleWidthPx + baseSpacingPx) * oldScale + val newStepPx = (baseCandleWidthPx + baseSpacingPx) * newScale + val contentX = scrollState.value + centroid.x - chartStartPaddingPx + val stepIndex = if (oldStepPx > 0f) contentX / oldStepPx else 0f + + candleScale = newScale + + val newTotalWidthPx = chartStartPaddingPx + + (baseCandleWidthPx * newScale * items.size) + + (baseSpacingPx * newScale * (items.size - 1).coerceAtLeast(0)) + val maxScroll = (newTotalWidthPx - viewportWidthPx).coerceAtLeast(0f) + val anchoredScroll = (stepIndex * newStepPx) - (centroid.x - chartStartPaddingPx) + val targetScroll = (anchoredScroll - pan.x).roundToInt() + .coerceIn(0, maxScroll.roundToInt()) + + coroutineScope.launch { + scrollState.scrollTo(targetScroll) + } + } + ) + } + .pointerInput(items.size, totalChartWidthPx, isPinching) { + if (isPinching) return@pointerInput detectDragGesturesAfterLongPress( onDragStart = { offset -> isTouching = true @@ -253,7 +311,7 @@ private fun ScrollableCandleChart( } ) } - .horizontalScroll(scrollState, enabled = !isTouching) + .horizontalScroll(scrollState, enabled = !isTouching && !isPinching) .clipToBounds() ) { PerpsCandleChartCanvas( From 5a7d53748d6ebca60158ddaa45b46c9c91f7fcdb Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 20 Mar 2026 09:31:43 +0800 Subject: [PATCH 105/105] Refactor compose bottom sheets into fragments --- .../AddressSearchBottomSheetDialogFragment.kt | 96 +++ .../TransferDestinationInputFragment.kt | 58 +- .../address/page/AddressSearchBottomSheet.kt | 39 +- .../page/TransferDestinationInputPage.kt | 566 +++++++++--------- .../ui/home/web3/trade/TradeFragment.kt | 7 + .../TradeHelpBottomSheetDialogFragment.kt | 78 +++ .../android/ui/home/web3/trade/TradePage.kt | 44 +- .../android/ui/landing/SetupPinFragment.kt | 7 + .../android/ui/landing/components/QuizPage.kt | 59 +- .../QuizResultBottomSheetDialogFragment.kt | 84 +++ .../WalletListBottomSheetDialogFragment.kt | 17 +- .../android/ui/wallet/alert/AlertEditPage.kt | 73 +-- .../android/ui/wallet/alert/AlertFragment.kt | 23 +- ...AlertSelectionBottomSheetDialogFragment.kt | 105 ++++ 14 files changed, 787 insertions(+), 469 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/address/AddressSearchBottomSheetDialogFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeHelpBottomSheetDialogFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/landing/components/QuizResultBottomSheetDialogFragment.kt create mode 100644 app/src/main/java/one/mixin/android/ui/wallet/alert/AlertSelectionBottomSheetDialogFragment.kt diff --git a/app/src/main/java/one/mixin/android/ui/address/AddressSearchBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/address/AddressSearchBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..192015e6a0 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/address/AddressSearchBottomSheetDialogFragment.kt @@ -0,0 +1,96 @@ +package one.mixin.android.ui.address + +import android.annotation.SuppressLint +import android.app.Dialog +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.withArgs +import one.mixin.android.ui.address.page.AddressSearchBottomSheet +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.util.SystemUIManager +import one.mixin.android.vo.Address + +@AndroidEntryPoint +class AddressSearchBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + companion object { + const val TAG = "AddressSearchBottomSheetDialogFragment" + private const val ARGS_CHAIN_ID = "args_chain_id" + + fun newInstance(chainId: String) = AddressSearchBottomSheetDialogFragment().withArgs { + putString(ARGS_CHAIN_ID, chainId) + } + } + + private val viewModel by viewModels() + private val chainId by lazy { requireArguments().getString(ARGS_CHAIN_ID).orEmpty() } + + var onAddressClick: ((Address) -> Unit)? = null + var onAddClick: (() -> Unit)? = null + var onDeleteAddress: ((Address) -> Unit)? = null + + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + + @Composable + override fun ComposeContent() { + MixinAppTheme { + val addresses by viewModel.addressesFlow(chainId).collectAsState(initial = emptyList()) + AddressSearchBottomSheet( + addresses = addresses, + onAddressClick = { address -> + dismiss() + onAddressClick?.invoke(address) + }, + onAddClick = { + dismiss() + onAddClick?.invoke() + }, + onDeleteAddress = { address -> + onDeleteAddress?.invoke(address) + }, + onDismiss = { dismiss() } + ) + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + override fun showError(error: String) { + } +} diff --git a/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt b/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt index 5230f91d15..79063ce0be 100644 --- a/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/address/TransferDestinationInputFragment.kt @@ -488,6 +488,63 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres } else { toast(R.string.Data_error) } + }, + onShowAddressBook = { + val chainId = token?.chainId ?: web3Token?.chainId ?: return@TransferDestinationInputPage + AddressSearchBottomSheetDialogFragment.newInstance(chainId).apply { + onAddressClick = { address -> + requireView().hideKeyboard() + if (web3Token != null) { + val dialog = + indeterminateProgressDialog(message = R.string.Please_wait_a_bit).apply { + setCancelable(false) + } + lifecycleScope.launch { + dialog.show() + val fromAddress = web3ViewModel.getAddressesByChainId(web3Token!!.walletId, web3Token!!.chainId)?.destination + if (fromAddress.isNullOrBlank()) { + toast(R.string.Alert_Not_Support) + } else { + (chainToken ?: web3ViewModel.web3TokenItemById( + web3Token!!.walletId, + web3Token!!.chainId + ))?.let { chain -> + navigateToInputFragmentWithBundle(Bundle().apply { + putString(InputFragment.ARGS_FROM_ADDRESS, fromAddress) + putString(InputFragment.ARGS_TO_ADDRESS, address.destination) + putString(InputFragment.ARGS_TO_ADDRESS_TAG, address.tag) + putParcelable(InputFragment.ARGS_WEB3_TOKEN, web3Token!!) + putParcelable(InputFragment.ARGS_WEB3_CHAIN_TOKEN, chain) + putParcelable(ARGS_WALLET, wallet) + }) + } + } + dialog.dismiss() + } + } else if (token != null) { + navigateToInputFragmentWithBundle(Bundle().apply { + putParcelable(InputFragment.ARGS_TOKEN, token) + putString(InputFragment.ARGS_TO_ADDRESS, address.destination) + putString(InputFragment.ARGS_TO_ADDRESS_TAG, address.tag) + }) + } + } + onDeleteAddress = { address -> + if (token == null && web3Token != null) { + lifecycleScope.launch { + val t = web3ViewModel.syncAsset(web3Token!!.assetId) ?: return@launch + showDeleteBottomSheet(address, t) + } + } else if (token != null) { + showDeleteBottomSheet(address, token!!) + } else { + toast(R.string.Data_error) + } + } + onAddClick = { + navController.navigate(TransferDestination.Address.name) + } + }.show(parentFragmentManager, AddressSearchBottomSheetDialogFragment.TAG) } ) } @@ -819,4 +876,3 @@ class TransferDestinationInputFragment() : BaseFragment(R.layout.fragment_addres return bottomSheet } } - diff --git a/app/src/main/java/one/mixin/android/ui/address/page/AddressSearchBottomSheet.kt b/app/src/main/java/one/mixin/android/ui/address/page/AddressSearchBottomSheet.kt index a578f718cc..a6603e4455 100644 --- a/app/src/main/java/one/mixin/android/ui/address/page/AddressSearchBottomSheet.kt +++ b/app/src/main/java/one/mixin/android/ui/address/page/AddressSearchBottomSheet.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton -import androidx.compose.material.ModalBottomSheetState import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -56,10 +55,10 @@ import one.mixin.android.vo.displayAddress @Composable fun AddressSearchBottomSheet( addresses: List
, - modalSheetState: ModalBottomSheetState, onAddressClick: (Address) -> Unit, onAddClick: () -> Unit, onDeleteAddress: (Address) -> Unit, + onDismiss: () -> Unit, ) { var isDeleteMode by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } @@ -74,13 +73,9 @@ fun AddressSearchBottomSheet( } } } - BackHandler ( - enabled = modalSheetState.isVisible || isDeleteMode - ) { - scope.launch { - if (isDeleteMode) isDeleteMode = false - else modalSheetState.hide() - } + BackHandler(enabled = true) { + if (isDeleteMode) isDeleteMode = false + else onDismiss() } val actionBarHeight = with(LocalDensity.current) { @@ -114,7 +109,7 @@ fun AddressSearchBottomSheet( modifier = Modifier.weight(1f) ) - if (searchText.isNotEmpty() || modalSheetState != null) { + if (searchText.isNotEmpty() || isDeleteMode) { Text( text = stringResource(R.string.Cancel), color = MixinAppTheme.colors.textBlue, @@ -126,10 +121,8 @@ fun AddressSearchBottomSheet( searchText = "" } else if (isDeleteMode) { isDeleteMode = false - } else if (modalSheetState != null) { - scope.launch { - modalSheetState.hide() - } + } else { + onDismiss() } }) } @@ -143,10 +136,8 @@ fun AddressSearchBottomSheet( .fillMaxHeight() ) { Row(modifier = Modifier.clickable { - scope.launch { - modalSheetState.hide() - onAddClick() - } + onDismiss() + onAddClick() }) { Icon( painter = painterResource(id = R.drawable.ic_add_blue_24dp), @@ -175,10 +166,8 @@ fun AddressSearchBottomSheet( onDeleteClick = onDeleteAddress, onAddressClick = { isDeleteMode = false - scope.launch { - modalSheetState.hide() - onAddressClick.invoke(address) - } + onDismiss() + onAddressClick.invoke(address) }, ) } @@ -233,10 +222,8 @@ fun AddressSearchBottomSheet( IconButton( onClick = { - scope.launch { - modalSheetState?.hide() - onAddClick() - } + onDismiss() + onAddClick() } ) { Icon( diff --git a/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt b/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt index 405b0e051f..25b45c9d9b 100644 --- a/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt +++ b/app/src/main/java/one/mixin/android/ui/address/page/TransferDestinationInputPage.kt @@ -14,28 +14,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.CircularProgressIndicator import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.IconButton -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,7 +56,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.Constants.ChainId import one.mixin.android.R @@ -95,18 +90,17 @@ fun TransferDestinationInputPage( onSend: (String) -> Unit, onDeleteAddress: (Address) -> Unit, onAddressClick: (Address) -> Unit, + onShowAddressBook: () -> Unit, ) { val context = LocalContext.current val localLocalSoftwareKeyboardController = LocalSoftwareKeyboardController.current - val scope = rememberCoroutineScope() val viewModel: AddressViewModel = hiltViewModel() - val addresses by viewModel.addressesFlow(token?.chainId ?: web3Token?.chainId ?: "") - .collectAsState(initial = emptyList()) - var walletDisplayName by remember { mutableStateOf(null) } var hasSafeWallet by remember { mutableStateOf(false) } var safeWalletChainId by remember { mutableStateOf(null) } + var text by remember(contentText) { mutableStateOf(contentText) } + val clipboardManager = LocalClipboard.current LaunchedEffect(web3Token?.walletId) { if (web3Token?.walletId != null) { @@ -114,7 +108,8 @@ fun TransferDestinationInputPage( if (it.category == WalletCategory.CLASSIC.value || it.category == WalletCategory.IMPORTED_MNEMONIC.value || it.category == WalletCategory.IMPORTED_PRIVATE_KEY.value || - it.category == WalletCategory.WATCH_ADDRESS.value) { + it.category == WalletCategory.WATCH_ADDRESS.value + ) { walletDisplayName = it.name } } @@ -128,315 +123,298 @@ fun TransferDestinationInputPage( safeWalletChainId = safeWallets.firstOrNull()?.safeChainId } - val modalSheetState = rememberModalBottomSheetState( - initialValue = if (addressShown) ModalBottomSheetValue.Expanded else ModalBottomSheetValue.Hidden, - skipHalfExpanded = true - ) - var text by remember(contentText) { mutableStateOf(contentText) } - val clipboardManager = LocalClipboard.current + LaunchedEffect(addressShown) { + if (addressShown) { + onShowAddressBook() + } + } - ModalBottomSheetLayout( - sheetState = modalSheetState, - scrimColor = Color.Black.copy(alpha = 0.3f), - sheetBackgroundColor = Color.Transparent, - sheetContent = { - AddressSearchBottomSheet( - addresses = addresses, - modalSheetState = modalSheetState, - onAddClick = toAddAddress, - onDeleteAddress = onDeleteAddress, - onAddressClick = onAddressClick + PageScaffold( + title = stringResource(R.string.Send), + subtitle = { + val subtitleText = when { + name != null -> name + web3Token != null -> walletDisplayName ?: stringResource(R.string.Common_Wallet) + else -> stringResource(R.string.Privacy_Wallet) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = subtitleText, + fontSize = 12.sp, + lineHeight = 16.sp, + color = MixinAppTheme.colors.textAssist, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (name == null && web3Token == null) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_wallet_privacy), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(12.dp) + ) + } + } + }, + verticalScrollable = false, + pop = pop, + actions = { + IconButton(onClick = { + context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + }) { + Icon( + painter = painterResource(id = R.drawable.ic_support), + contentDescription = null, + tint = MixinAppTheme.colors.icon, ) } + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .imePadding(), ) { - PageScaffold( - title = stringResource(R.string.Send), - subtitle = { - val subtitleText = when { - name != null -> name - web3Token != null -> walletDisplayName ?: stringResource(R.string.Common_Wallet) - else -> stringResource(R.string.Privacy_Wallet) - } - Row(verticalAlignment = Alignment.CenterVertically) { + TokenInfoHeader(token = token, web3Token = web3Token) + Box( + modifier = Modifier + .fillMaxWidth() + .cardBackground( + Color.Transparent, + MixinAppTheme.colors.borderColor, + cornerRadius = 8.dp + ), + ) { + OutlinedTextField( + value = text, + onValueChange = { + text = it + }, + modifier = Modifier + .wrapContentHeight() + .heightIn(min = 120.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + backgroundColor = Color.Transparent, + textColor = MixinAppTheme.colors.textPrimary, + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + cursorColor = MixinAppTheme.colors.textBlue + ), + placeholder = { Text( - text = subtitleText, - fontSize = 12.sp, - lineHeight = 16.sp, + text = stringResource(R.string.hint_address), color = MixinAppTheme.colors.textAssist, - maxLines = 1, - overflow = TextOverflow.Ellipsis + fontSize = 14.sp, + lineHeight = 20.sp ) - if (name == null && web3Token == null) { // Privacy Wallet - Spacer(modifier = Modifier.width(4.dp)) - Icon( - painter = painterResource(id = R.drawable.ic_wallet_privacy), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.size(12.dp) - ) + }, + textStyle = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textPrimary, + textAlign = TextAlign.Start + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, imeAction = ImeAction.Done + ), + minLines = 3, + maxLines = 20, + visualTransformation = if (text.isExternalTransferUrl() || text.isLightningUrl()) { + VisualTransformation { input -> + val inputText = input.text + if (inputText.length <= 12) { + return@VisualTransformation TransformedText(input, OffsetMapping.Identity) + } + + val annotatedString = buildAnnotatedString { + append(inputText) + addStyle( + style = SpanStyle(fontWeight = FontWeight.ExtraBold), + start = 0, + end = 6.coerceAtMost(inputText.length) + ) + addStyle( + style = SpanStyle(fontWeight = FontWeight.ExtraBold), + start = (inputText.length - 6).coerceAtLeast(0), + end = inputText.length + ) + } + TransformedText(annotatedString, OffsetMapping.Identity) } + } else { + VisualTransformation.None } - }, - verticalScrollable = false, - pop = pop, - actions = { - IconButton(onClick = { - context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) - }) { + ) + + if (text.isNotBlank()) { + IconButton( + onClick = { + text = "" + }, + modifier = Modifier.align(Alignment.BottomEnd) + ) { Icon( - painter = painterResource(id = R.drawable.ic_support), + painter = painterResource(R.drawable.ic_addr_remove), contentDescription = null, - tint = MixinAppTheme.colors.icon, + tint = MixinAppTheme.colors.iconGray ) } - } - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .imePadding(), - ) { - TokenInfoHeader(token = token, web3Token = web3Token) - Box( - modifier = Modifier - .fillMaxWidth() - .cardBackground( - Color.Transparent, - MixinAppTheme.colors.borderColor, - cornerRadius = 8.dp - ), - ) { - OutlinedTextField( - value = text, - onValueChange = { - text = it - }, - modifier = Modifier - .wrapContentHeight() - .heightIn(min = 120.dp), - colors = TextFieldDefaults.outlinedTextFieldColors( - backgroundColor = Color.Transparent, - textColor = MixinAppTheme.colors.textPrimary, - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - cursorColor = MixinAppTheme.colors.textBlue - ), - placeholder = { - Text( - text = stringResource(R.string.hint_address), - color = MixinAppTheme.colors.textAssist, - fontSize = 14.sp, - lineHeight = 20.sp - ) - }, - textStyle = TextStyle( - fontSize = 14.sp, - lineHeight = 20.sp, - color = MixinAppTheme.colors.textPrimary, - textAlign = TextAlign.Start - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, imeAction = ImeAction.Done - ), - minLines = 3, - maxLines = 20, - visualTransformation = if (text.isExternalTransferUrl() || text.isLightningUrl()) { - VisualTransformation { input -> - val inputText = input.text - if (inputText.length <= 12) return@VisualTransformation TransformedText( - input, - OffsetMapping.Identity - ) - - val annotatedString = buildAnnotatedString { - append(inputText) - addStyle( - style = SpanStyle(fontWeight = FontWeight.ExtraBold), - start = 0, - end = 6.coerceAtMost(inputText.length) - ) - addStyle( - style = SpanStyle(fontWeight = FontWeight.ExtraBold), - start = (inputText.length - 6).coerceAtLeast(0), - end = inputText.length - ) - } - TransformedText(annotatedString, OffsetMapping.Identity) - } - } else { - VisualTransformation.None - } - ) - - if (text.isNotBlank()) { - IconButton( - onClick = { - text = "" - }, modifier = Modifier.align(Alignment.BottomEnd) - ) { - Icon( - painter = painterResource(R.drawable.ic_addr_remove), - contentDescription = null, - tint = MixinAppTheme.colors.iconGray - ) - } - } else { - Row(modifier = Modifier.align(Alignment.BottomEnd)) { - IconButton( - onClick = { - clipboardManager.nativeClipboard.primaryClip?.getItemAt(0)?.text?.let { - text = it.toString() - } - }, - ) { - Icon( - painter = painterResource(R.drawable.ic_paste), - contentDescription = null, - tint = MixinAppTheme.colors.iconGray - ) - } - Spacer(modifier = Modifier.width(16.dp)) - IconButton( - onClick = { - onScan?.invoke() - } - ) { - Icon( - painter = painterResource(R.drawable.ic_scan), - contentDescription = null, - tint = MixinAppTheme.colors.iconGray - ) + } else { + Row(modifier = Modifier.align(Alignment.BottomEnd)) { + IconButton( + onClick = { + clipboardManager.nativeClipboard.primaryClip?.getItemAt(0)?.text?.let { + text = it.toString() } + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_paste), + contentDescription = null, + tint = MixinAppTheme.colors.iconGray + ) + } + Spacer(modifier = Modifier.width(16.dp)) + IconButton( + onClick = { + onScan?.invoke() } + ) { + Icon( + painter = painterResource(R.drawable.ic_scan), + contentDescription = null, + tint = MixinAppTheme.colors.iconGray + ) } } + } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - if (text.isBlank()) { - Column { - if (token != null || web3Token?.assetId != null) { - DestinationMenu( - R.drawable.ic_destination_address, - R.string.Address_Book, - R.string.send_to_address_description, - onClick = { - localLocalSoftwareKeyboardController?.hide() - scope.launch { - modalSheetState.show() - } - } - ) - Spacer(modifier = Modifier.height(16.dp)) - } - if (token != null) { - DestinationMenu( - R.drawable.ic_destination_contact, - R.string.Mixin_Contact, - R.string.send_to_contact_description, - onClick = { - toContact.invoke() - }, true - ) - Spacer(modifier = Modifier.height(16.dp)) - } else { - DestinationMenu( - R.drawable.ic_destination_contact, - R.string.Mixin_Contact, - R.string.send_to_mixin_contact_description, - onClick = { - toContact.invoke() - }, true - ) - Spacer(modifier = Modifier.height(16.dp)) - } - if (web3Token != null) { - DestinationMenu( - R.drawable.ic_destination_wallet, - R.string.My_Wallet, - stringResource(R.string.send_to_my_wallet_description), - free = true, - onClick = { - toWallet.invoke(web3Token.walletId) - }, - isPrivacy = false - ) - Spacer(modifier = Modifier.height(16.dp)) - } else if (hasSafeWallet && safeWalletChainId != null) { - // Show Safe wallet option - DestinationMenu( - R.drawable.ic_destination_wallet, - R.string.My_Wallet, - stringResource(R.string.send_to_my_wallet_description), - free = true, - onClick = { - toWallet.invoke(null) - }, - isPrivacy = false - ) - Spacer(modifier = Modifier.height(16.dp)) - } else if (token?.chainId == ChainId.SOLANA_CHAIN_ID || - token?.chainId == ChainId.BITCOIN_CHAIN_ID || - token?.chainId in Constants.Web3EvmChainIds - ) { - DestinationMenu( - R.drawable.ic_destination_wallet, - stringResource(R.string.My_Wallet), - stringResource(R.string.send_to_my_wallet_description), - onClick = { - toWallet.invoke(null) - }, - free = true - ) - Spacer(modifier = Modifier.height(16.dp)) + if (text.isBlank()) { + Column { + if (token != null || web3Token?.assetId != null) { + DestinationMenu( + R.drawable.ic_destination_address, + R.string.Address_Book, + R.string.send_to_address_description, + onClick = { + localLocalSoftwareKeyboardController?.hide() + onShowAddressBook() } - } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + if (token != null) { + DestinationMenu( + R.drawable.ic_destination_contact, + R.string.Mixin_Contact, + R.string.send_to_contact_description, + onClick = { + toContact.invoke() + }, + true + ) + Spacer(modifier = Modifier.height(16.dp)) } else { - Text( - text = errorInfo ?: "", - color = MixinAppTheme.colors.red, - modifier = Modifier - .padding(vertical = 8.dp) - .align(Alignment.CenterHorizontally) - .alpha(if (errorInfo.isNullOrBlank()) 0f else 1f) + DestinationMenu( + R.drawable.ic_destination_contact, + R.string.Mixin_Contact, + R.string.send_to_mixin_contact_description, + onClick = { + toContact.invoke() + }, + true ) - Button( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), + Spacer(modifier = Modifier.height(16.dp)) + } + if (web3Token != null) { + DestinationMenu( + R.drawable.ic_destination_wallet, + R.string.My_Wallet, + stringResource(R.string.send_to_my_wallet_description), + free = true, onClick = { - onSend.invoke(text) + toWallet.invoke(web3Token.walletId) }, - enabled = text.isBlank().not() && !isLoading, - colors = ButtonDefaults.outlinedButtonColors( - backgroundColor = if (text.isBlank().not()) MixinAppTheme.colors.accent else MixinAppTheme.colors.backgroundGrayLight, - ), - shape = RoundedCornerShape(32.dp), - elevation = ButtonDefaults.elevation( - pressedElevation = 0.dp, - defaultElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp, - ), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - Text( - text = stringResource(R.string.Send), - color = if (text.isBlank()) MixinAppTheme.colors.textAssist else Color.White, - ) - } - } + isPrivacy = false + ) + Spacer(modifier = Modifier.height(16.dp)) + } else if (hasSafeWallet && safeWalletChainId != null) { + DestinationMenu( + R.drawable.ic_destination_wallet, + R.string.My_Wallet, + stringResource(R.string.send_to_my_wallet_description), + free = true, + onClick = { + toWallet.invoke(null) + }, + isPrivacy = false + ) + Spacer(modifier = Modifier.height(16.dp)) + } else if ( + token?.chainId == ChainId.SOLANA_CHAIN_ID || + token?.chainId == ChainId.BITCOIN_CHAIN_ID || + token?.chainId in Constants.Web3EvmChainIds + ) { + DestinationMenu( + R.drawable.ic_destination_wallet, + stringResource(R.string.My_Wallet), + stringResource(R.string.send_to_my_wallet_description), + onClick = { + toWallet.invoke(null) + }, + free = true + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } else { + Text( + text = errorInfo ?: "", + color = MixinAppTheme.colors.red, + modifier = Modifier + .padding(vertical = 8.dp) + .align(Alignment.CenterHorizontally) + .alpha(if (errorInfo.isNullOrBlank()) 0f else 1f) + ) + Button( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + onClick = { + onSend.invoke(text) + }, + enabled = text.isBlank().not() && !isLoading, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = if (text.isBlank().not()) MixinAppTheme.colors.accent else MixinAppTheme.colors.backgroundGrayLight, + ), + shape = RoundedCornerShape(32.dp), + elevation = ButtonDefaults.elevation( + pressedElevation = 0.dp, + defaultElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp, + ), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Text( + text = stringResource(R.string.Send), + color = if (text.isBlank()) MixinAppTheme.colors.textAssist else Color.White, + ) } } } } } - +} 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 0b21fafacf..1b7e91040d 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 @@ -394,6 +394,13 @@ class TradeFragment : BaseFragment() { } } }, + onShowHelpBottomSheet = { onContactSupport, onTradingGuide -> + this@apply.hideKeyboard() + TradeHelpBottomSheetDialogFragment.newInstance().apply { + this.onContactSupport = onContactSupport + this.onTradingGuide = onTradingGuide + }.show(parentFragmentManager, TradeHelpBottomSheetDialogFragment.TAG) + }, pop = { navigateUp(navController) }, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeHelpBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeHelpBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..950c3fe29f --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeHelpBottomSheetDialogFragment.kt @@ -0,0 +1,78 @@ +package one.mixin.android.ui.home.web3.trade + +import android.annotation.SuppressLint +import android.app.Dialog +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.screenHeight +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.util.SystemUIManager + +@AndroidEntryPoint +class TradeHelpBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + companion object { + const val TAG = "TradeHelpBottomSheetDialogFragment" + + fun newInstance() = TradeHelpBottomSheetDialogFragment() + } + + var onContactSupport: (() -> Unit)? = null + var onTradingGuide: (() -> Unit)? = null + + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + + @Composable + override fun ComposeContent() { + MixinAppTheme { + HelpBottomSheetContent( + onContactSupport = { + dismiss() + onContactSupport?.invoke() + }, + onTradingGuide = { + dismiss() + onTradingGuide?.invoke() + }, + onDismiss = { dismiss() } + ) + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + override fun showError(error: String) { + } +} 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 3873bab094..28d8faf8fd 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 @@ -22,10 +22,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.IconButton -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.Text -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -99,6 +96,7 @@ fun TradePage( pop: () -> Unit, onLimitOrderClick: (String) -> Unit, onShowTradingGuide: (Int) -> Unit, + onShowHelpBottomSheet: (() -> Unit, () -> Unit) -> Unit, onShowMarketList: (Boolean) -> Unit, onShowAllMarkets: () -> Unit, onShowAllOpenPositions: () -> Unit, @@ -113,11 +111,6 @@ fun TradePage( val perpsViewModel = hiltViewModel() var walletDisplayName by remember { mutableStateOf(null) } var pendingOrderCount by remember { mutableIntStateOf(0) } - - val bottomSheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = true - ) val coroutineScope = rememberCoroutineScope() val currentWalletId = walletId ?: Session.getAccountId() ?: "" @@ -225,33 +218,6 @@ fun TradePage( } } - ModalBottomSheetLayout( - sheetState = bottomSheetState, - scrimColor = Color.Black.copy(alpha = 0.6f), - sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - sheetBackgroundColor = MixinAppTheme.colors.background, - sheetContent = { - HelpBottomSheetContent( - onContactSupport = { - coroutineScope.launch { - bottomSheetState.hide() - context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) - } - }, - onTradingGuide = { - coroutineScope.launch { - bottomSheetState.hide() - onShowTradingGuide(pagerState.currentPage) - } - }, - onDismiss = { - coroutineScope.launch { - bottomSheetState.hide() - } - } - ) - } - ) { PageScaffold( title = stringResource(id = R.string.Trade), subtitle = { @@ -358,9 +324,10 @@ fun TradePage( } } IconButton(onClick = { - coroutineScope.launch { - bottomSheetState.show() - } + onShowHelpBottomSheet( + { context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) }, + { onShowTradingGuide(pagerState.currentPage) } + ) }) { Icon( painter = painterResource(id = R.drawable.ic_support), @@ -415,7 +382,6 @@ fun TradePage( } } } -} /** * @return True if the input was successful, false if the balance is insufficient, or null if the input is invalid. diff --git a/app/src/main/java/one/mixin/android/ui/landing/SetupPinFragment.kt b/app/src/main/java/one/mixin/android/ui/landing/SetupPinFragment.kt index edb8dfb949..9b6092de76 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/SetupPinFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/SetupPinFragment.kt @@ -20,6 +20,7 @@ import one.mixin.android.databinding.FragmentComposeBinding import one.mixin.android.extension.isNightMode import one.mixin.android.ui.common.BaseFragment import one.mixin.android.ui.landing.components.QuizPage +import one.mixin.android.ui.landing.components.QuizResultBottomSheetDialogFragment import one.mixin.android.ui.landing.components.SetPinLoadingPage import one.mixin.android.ui.landing.components.SetupPinPage import one.mixin.android.ui.logs.LogViewerBottomSheet @@ -120,6 +121,12 @@ class SetupPinFragment : BaseFragment(R.layout.fragment_compose) { pop = { Timber.e("$TAG Quiz back pressed") navController.popBackStack() + }, + onShowResultBottomSheet = { isCorrect, onCorrect, onWrong -> + QuizResultBottomSheetDialogFragment.newInstance(isCorrect).apply { + onCorrectAction = onCorrect + onWrongAction = onWrong + }.show(parentFragmentManager, QuizResultBottomSheetDialogFragment.TAG) } ) } diff --git a/app/src/main/java/one/mixin/android/ui/landing/components/QuizPage.kt b/app/src/main/java/one/mixin/android/ui/landing/components/QuizPage.kt index a0f9873f8b..a0ed135616 100644 --- a/app/src/main/java/one/mixin/android/ui/landing/components/QuizPage.kt +++ b/app/src/main/java/one/mixin/android/ui/landing/components/QuizPage.kt @@ -17,18 +17,13 @@ import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.IconButton -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.RadioButton import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Text -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,48 +44,15 @@ import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.openUrl @Composable -fun QuizPage(next: () -> Unit, pop: (() -> Unit)? = null) { +fun QuizPage( + next: () -> Unit, + pop: (() -> Unit)? = null, + onShowResultBottomSheet: (Boolean, () -> Unit, () -> Unit) -> Unit, +) { val context = LocalContext.current var selectedOption by remember { mutableStateOf(-1) } - var showDialog by remember { mutableStateOf(false) } var isCorrectAnswer by remember { mutableStateOf(false) } - val bottomSheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = true, - ) - val coroutineScope = rememberCoroutineScope() - - LaunchedEffect(showDialog) { - if (showDialog) { - bottomSheetState.show() - } - } - - ModalBottomSheetLayout( - sheetState = bottomSheetState, - scrimColor = Color.Black.copy(alpha = 0.6f), - sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - sheetBackgroundColor = MixinAppTheme.colors.background, - sheetContent = { - QuizResultBottomSheetContent( - isCorrect = isCorrectAnswer, - onCorrectAction = { - coroutineScope.launch { - bottomSheetState.hide() - showDialog = false - next() - } - }, - onWrongAction = { - coroutineScope.launch { - bottomSheetState.hide() - showDialog = false - selectedOption = -1 - } - } - ) - } - ) { + PageScaffold( title = "", verticalScrollable = false, @@ -129,7 +91,11 @@ fun QuizPage(next: () -> Unit, pop: (() -> Unit)? = null) { onClick = { if (selectedOption != -1) { isCorrectAnswer = selectedOption == 1 - showDialog = true + onShowResultBottomSheet( + isCorrectAnswer, + { next() }, + { selectedOption = -1 } + ) } }, enabled = selectedOption != -1, @@ -162,7 +128,6 @@ fun QuizPage(next: () -> Unit, pop: (() -> Unit)? = null) { } } } - } } @Composable @@ -266,4 +231,4 @@ fun QuizResultBottomSheetContent( Spacer(modifier = Modifier.height(16.dp)) } -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/landing/components/QuizResultBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/landing/components/QuizResultBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..8f198ea492 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/landing/components/QuizResultBottomSheetDialogFragment.kt @@ -0,0 +1,84 @@ +package one.mixin.android.ui.landing.components + +import android.annotation.SuppressLint +import android.app.Dialog +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.screenHeight +import one.mixin.android.extension.withArgs +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.util.SystemUIManager + +@AndroidEntryPoint +class QuizResultBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + companion object { + const val TAG = "QuizResultBottomSheetDialogFragment" + private const val ARGS_IS_CORRECT = "args_is_correct" + + fun newInstance(isCorrect: Boolean) = QuizResultBottomSheetDialogFragment().withArgs { + putBoolean(ARGS_IS_CORRECT, isCorrect) + } + } + + private val isCorrect by lazy { requireArguments().getBoolean(ARGS_IS_CORRECT) } + + var onCorrectAction: (() -> Unit)? = null + var onWrongAction: (() -> Unit)? = null + + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + + @Composable + override fun ComposeContent() { + MixinAppTheme { + QuizResultBottomSheetContent( + isCorrect = isCorrect, + onCorrectAction = { + dismiss() + onCorrectAction?.invoke() + }, + onWrongAction = { + dismiss() + onWrongAction?.invoke() + } + ) + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + override fun showError(error: String) { + } +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/WalletListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/WalletListBottomSheetDialogFragment.kt index af418d2ead..59e45f28e2 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/WalletListBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/WalletListBottomSheetDialogFragment.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -32,6 +31,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -49,6 +49,8 @@ import kotlinx.coroutines.launch import one.mixin.android.Constants import one.mixin.android.R +import one.mixin.android.compose.GetActionBarHeight +import one.mixin.android.compose.GetStatusBarHeightValue import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.db.web3.vo.WalletItem import one.mixin.android.db.web3.vo.isClassic @@ -241,8 +243,13 @@ fun WalletListScreen( } } - Column(modifier = Modifier - .fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .height( + LocalConfiguration.current.screenHeightDp.dp - GetActionBarHeight() - GetStatusBarHeightValue() + ) + ) { SearchBar( query = query, onQueryChanged = { @@ -257,7 +264,7 @@ fun WalletListScreen( Column( modifier = Modifier .padding(top = 8.dp, start = 16.dp, end = 16.dp) - .fillMaxSize() + .weight(1f) .verticalScroll(rememberScrollState()) ) { if (hasAll || hasSafe || hasImported || hasWatch || hasCreated) { @@ -432,4 +439,4 @@ fun SearchBar( .padding(start = 16.dp) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt index a9235863fc..50440a2c31 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertEditPage.kt @@ -26,11 +26,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetState -import androidx.compose.material.ModalBottomSheetValue.Hidden import androidx.compose.material.Text -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -100,17 +96,19 @@ fun Modifier.draw9Patch( } @Composable -fun AlertEditPage(coin: CoinItem?, alert: Alert?, onAdd: (CoinItem) -> Unit, pop: () -> Unit) { +fun AlertEditPage( + coin: CoinItem?, + alert: Alert?, + onAdd: (CoinItem) -> Unit, + pop: () -> Unit, + onShowTypeSelector: (AlertType, (AlertType) -> Unit) -> Unit, + onShowFrequencySelector: (AlertFrequency, (AlertFrequency) -> Unit) -> Unit, +) { MixinAppTheme { - val bottomSheetState: ModalBottomSheetState = rememberModalBottomSheetState( - Hidden, - skipHalfExpanded = true - ) val coroutineScope = rememberCoroutineScope() if (coin != null) { val context = LocalContext.current val currentPrice = BigDecimal(coin.currentPrice) - var expandType by remember { mutableStateOf(true) } var alertValue by remember { mutableStateOf(alert?.rawValue ?: currentPrice.toPlainString()) } val maxPrice = currentPrice.multiply(BigDecimal(100)) val minPrice = currentPrice.divide(BigDecimal(100)) @@ -121,42 +119,6 @@ fun AlertEditPage(coin: CoinItem?, alert: Alert?, onAdd: (CoinItem) -> Unit, pop var isLoading by remember { mutableStateOf(false) } var inputError by remember { mutableStateOf(if (alertValue.toBigDecimalOrNull() == currentPrice && selectedAlertType != AlertType.PRICE_REACHED) InputError.EQUALS_CURRENT_PRICE else null) } val viewModel = hiltViewModel() - ModalBottomSheetLayout( - sheetState = bottomSheetState, - scrimColor = Color.Black.copy(alpha = 0.3f), - sheetBackgroundColor = Color.Transparent, - sheetContent = { - if (expandType) { - AlertTypeBottom(selectedAlertType, { newType: AlertType -> - if (selectedAlertType != newType) { - inputError = null - alertValue = "" - selectedAlertType = newType - } - coroutineScope.launch { - bottomSheetState.hide() - } - }, { - coroutineScope.launch { - bottomSheetState.hide() - } - }) - } else { - AlertFrequencyBottom(selectedAlertFrequency, { newFrequency: AlertFrequency -> - if (selectedAlertFrequency != newFrequency) { - selectedAlertFrequency = newFrequency - } - coroutineScope.launch { - bottomSheetState.hide() - } - }, { - coroutineScope.launch { - bottomSheetState.hide() - } - }) - } - }, - ) { PageScaffold( title = stringResource(id = if (alert == null) R.string.Alert else R.string.Edit_Alert), verticalScrollable = false, @@ -194,8 +156,13 @@ fun AlertEditPage(coin: CoinItem?, alert: Alert?, onAdd: (CoinItem) -> Unit, pop Spacer(modifier = Modifier.height(12.dp)) AlertTypeSelector(selectedType = selectedAlertType) { - expandType = true - coroutineScope.launch { bottomSheetState.show() } + onShowTypeSelector(selectedAlertType) { newType -> + if (selectedAlertType != newType) { + inputError = null + alertValue = "" + selectedAlertType = newType + } + } } Spacer(modifier = Modifier.height(6.dp)) @@ -424,9 +391,10 @@ fun AlertEditPage(coin: CoinItem?, alert: Alert?, onAdd: (CoinItem) -> Unit, pop Spacer(modifier = Modifier.height(14.dp)) AlertFrequencySelector(selectedAlertFrequency) { - coroutineScope.launch { - expandType = false - bottomSheetState.show() + onShowFrequencySelector(selectedAlertFrequency) { newFrequency -> + if (selectedAlertFrequency != newFrequency) { + selectedAlertFrequency = newFrequency + } } } } @@ -516,7 +484,6 @@ fun AlertEditPage(coin: CoinItem?, alert: Alert?, onAdd: (CoinItem) -> Unit, pop Spacer(modifier = Modifier.height(20.dp)) } } - } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertFragment.kt index d9d35a4c60..1d2137e0c0 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertFragment.kt @@ -141,11 +141,26 @@ class AlertFragment : BaseFragment(), MultiSelectCoinListBottomSheetDialogFragme } composable(AlertDestination.Edit.name) { - AlertEditPage(selectCoin, currentAlert, onAdd = { coin-> - if (coins.isNotEmpty()) { - coins = coins + coin + AlertEditPage( + selectCoin, + currentAlert, + onAdd = { coin -> + if (coins.isNotEmpty()) { + coins = coins + coin + } + }, + pop = { navigateUp(navController) }, + onShowTypeSelector = { selectedType, onSelected -> + AlertSelectionBottomSheetDialogFragment.newTypeInstance(selectedType).apply { + onTypeSelected = onSelected + }.show(parentFragmentManager, AlertSelectionBottomSheetDialogFragment.TAG) + }, + onShowFrequencySelector = { selectedFrequency, onSelected -> + AlertSelectionBottomSheetDialogFragment.newFrequencyInstance(selectedFrequency).apply { + onFrequencySelected = onSelected + }.show(parentFragmentManager, AlertSelectionBottomSheetDialogFragment.TAG) } - }, pop = { navigateUp(navController) }) + ) } } diff --git a/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertSelectionBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertSelectionBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..2f75462050 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/wallet/alert/AlertSelectionBottomSheetDialogFragment.kt @@ -0,0 +1,105 @@ +package one.mixin.android.ui.wallet.alert + +import android.annotation.SuppressLint +import android.app.Dialog +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import dagger.hilt.android.AndroidEntryPoint +import one.mixin.android.R +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.booleanFromAttribute +import one.mixin.android.extension.getSafeAreaInsetsTop +import one.mixin.android.extension.isNightMode +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.AlertFrequencyBottom +import one.mixin.android.ui.wallet.alert.components.AlertTypeBottom +import one.mixin.android.ui.wallet.alert.vo.AlertFrequency +import one.mixin.android.ui.wallet.alert.vo.AlertType +import one.mixin.android.util.SystemUIManager + +@AndroidEntryPoint +class AlertSelectionBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + companion object { + const val TAG = "AlertSelectionBottomSheetDialogFragment" + private const val ARGS_MODE = "args_mode" + private const val ARGS_SELECTED_TYPE = "args_selected_type" + private const val ARGS_SELECTED_FREQUENCY = "args_selected_frequency" + + private const val MODE_TYPE = "type" + private const val MODE_FREQUENCY = "frequency" + + fun newTypeInstance(selectedType: AlertType) = AlertSelectionBottomSheetDialogFragment().withArgs { + putString(ARGS_MODE, MODE_TYPE) + putString(ARGS_SELECTED_TYPE, selectedType.value) + } + + fun newFrequencyInstance(selectedFrequency: AlertFrequency) = AlertSelectionBottomSheetDialogFragment().withArgs { + putString(ARGS_MODE, MODE_FREQUENCY) + putString(ARGS_SELECTED_FREQUENCY, selectedFrequency.value) + } + } + + private val mode by lazy { requireArguments().getString(ARGS_MODE).orEmpty() } + private val selectedType by lazy { AlertType.values().first { it.value == requireArguments().getString(ARGS_SELECTED_TYPE) } } + private val selectedFrequency by lazy { AlertFrequency.values().first { it.value == requireArguments().getString(ARGS_SELECTED_FREQUENCY) } } + + var onTypeSelected: ((AlertType) -> Unit)? = null + var onFrequencySelected: ((AlertFrequency) -> Unit)? = null + + override fun getTheme() = R.style.AppTheme_Dialog + + @SuppressLint("RestrictedApi") + override fun setupDialog(dialog: Dialog, style: Int) { + super.setupDialog(dialog, R.style.MixinBottomSheet) + dialog.window?.let { window -> + SystemUIManager.lightUI(window, requireContext().isNightMode()) + } + dialog.window?.setGravity(Gravity.BOTTOM) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + override fun onStart() { + super.onStart() + dialog?.window?.let { window -> + SystemUIManager.lightUI( + window, + !requireContext().booleanFromAttribute(R.attr.flag_night), + ) + } + } + + @Composable + override fun ComposeContent() { + MixinAppTheme { + if (mode == MODE_TYPE) { + AlertTypeBottom(selectedType, { type -> + dismiss() + onTypeSelected?.invoke(type) + }, { + dismiss() + }) + } else { + AlertFrequencyBottom(selectedFrequency, { frequency -> + dismiss() + onFrequencySelected?.invoke(frequency) + }, { + dismiss() + }) + } + } + } + + override fun getBottomSheetHeight(view: View): Int { + return requireContext().screenHeight() - view.getSafeAreaInsetsTop() + } + + override fun showError(error: String) { + } +}