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..4b7954fffa --- /dev/null +++ b/app/schemas/one.mixin.android.db.PerpsDatabase/1.json @@ -0,0 +1,349 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "5fad6bcd4df78e3928b3aa50627c3d8c", + "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, `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", + "columnName": "position_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketId", + "columnName": "market_id", + "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": "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", + "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": "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", + "columnName": "history_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "positionId", + "columnName": "position_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "marketId", + "columnName": "market_id", + "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": "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", + "notNull": true + }, + { + "fieldPath": "openAt", + "columnName": "open_at", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "closedAt", + "columnName": "closed_at", + "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, `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", + "columnName": "market_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displaySymbol", + "columnName": "display_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tokenSymbol", + "columnName": "token_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quoteSymbol", + "columnName": "quote_symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "markPrice", + "columnName": "mark_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leverage", + "columnName": "leverage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fundingRate", + "columnName": "funding_rate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minAmount", + "columnName": "min_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxAmount", + "columnName": "max_amount", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "last", + "columnName": "last", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "volume", + "columnName": "volume", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "high", + "columnName": "high", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "low", + "columnName": "low", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "open", + "columnName": "open", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "change", + "columnName": "change", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bidPrice", + "columnName": "bid_price", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "askPrice", + "columnName": "ask_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": [ + "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, '5fad6bcd4df78e3928b3aa50627c3d8c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4884b4df10..9cda6cddb0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -163,6 +163,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/PerpsExt.kt b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt new file mode 100644 index 0000000000..895712cabe --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsExt.kt @@ -0,0 +1,24 @@ +package one.mixin.android.api.response.perps + +fun PerpsPositionItem.toPosition(): PerpsPosition { + return PerpsPosition( + positionId = positionId, + marketId = marketId, + side = side, + quantity = quantity, + entryPrice = entryPrice, + leverage = leverage, + 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 new file mode 100644 index 0000000000..d42daf3121 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsMarket.kt @@ -0,0 +1,89 @@ +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 + +@Entity(tableName = "markets") +data class PerpsMarket( + @PrimaryKey @SerializedName("market_id") + @ColumnInfo(name = "market_id") + val marketId: 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("min_amount") + @ColumnInfo(name = "min_amount") + val minAmount: String, + + @SerializedName("max_amount") + @ColumnInfo(name = "max_amount") + val maxAmount: String, + + @SerializedName("last") + @ColumnInfo(name = "last") + val last: String, + + @SerializedName("volume") + @ColumnInfo(name = "volume") + val volume: 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("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 new file mode 100644 index 0000000000..200f97b5a7 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPosition.kt @@ -0,0 +1,67 @@ +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 +@Entity(tableName = "positions") +data class PerpsPosition( + @PrimaryKey + @SerializedName("position_id") + @ColumnInfo(name = "position_id") + val positionId: String, + @SerializedName(value = "market_id") + @ColumnInfo(name = "market_id") + val marketId: 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") + @ColumnInfo(name = "roe") + 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, + @SerializedName("created_at") + @ColumnInfo(name = "created_at") + val createdAt: String, + @SerializedName("updated_at") + @ColumnInfo(name = "updated_at") + 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 new file mode 100644 index 0000000000..f72b67691d --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistory.kt @@ -0,0 +1,50 @@ +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 +@Entity(tableName = "position_histories") +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(value = "market_id") + @ColumnInfo(name = "market_id") + val marketId: 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("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, + @SerializedName("open_at") + @ColumnInfo(name = "open_at") + val openAt: String, + @SerializedName("closed_at") + @ColumnInfo(name = "closed_at") + val closedAt: 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..21ae0ff553 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionHistoryItem.kt @@ -0,0 +1,52 @@ +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(value = "market_id") + @ColumnInfo(name = "market_id") + val marketId: 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("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, + @SerializedName("open_at") + @ColumnInfo(name = "open_at") + val openAt: String, + @SerializedName("closed_at") + @ColumnInfo(name = "closed_at") + val closedAt: 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..4609974584 --- /dev/null +++ b/app/src/main/java/one/mixin/android/api/response/perps/PerpsPositionItem.kt @@ -0,0 +1,69 @@ +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(value = "market_id") + @ColumnInfo(name = "market_id") + val marketId: 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("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, + @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 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..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 @@ -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.PerpsMarket +import one.mixin.android.api.response.perps.CandleView +import one.mixin.android.api.response.perps.PerpsPosition +import one.mixin.android.api.response.perps.PerpsPositionHistory import retrofit2.http.Query @@ -344,4 +352,52 @@ 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/markets/{market_id}") + suspend fun getPerpsMarket( + @Path("market_id") marketId: String + ): MixinResponse + + @GET("perps/markets/candles") + suspend fun getPerpsCandles( + @Query("market_id") marketId: String, + @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 + ): 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/{id}") + suspend fun getPerpsPosition( + @Path("id") positionId: 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/db/PerpsDatabase.kt b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt new file mode 100644 index 0000000000..54516b7be6 --- /dev/null +++ b/app/src/main/java/one/mixin/android/db/PerpsDatabase.kt @@ -0,0 +1,87 @@ +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.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 +import java.io.File +import java.util.concurrent.Executors +import kotlin.math.max +import kotlin.math.min + +@Database( + entities = [ + PerpsPosition::class, + PerpsPositionHistory::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") + } + }, + ).fallbackToDestructiveMigration() + .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 perpsPositionHistoryDao(): PerpsPositionHistoryDao + abstract fun perpsMarketDao(): PerpsMarketDao + + override fun close() { + super.close() + INSTANCE = null + } +} 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..340501ebbf 100644 --- a/app/src/main/java/one/mixin/android/db/TokenDao.kt +++ b/app/src/main/java/one/mixin/android/db/TokenDao.kt @@ -135,10 +135,18 @@ 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> + @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) + @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) @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/db/perps/PerpsMarketDao.kt b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt new file mode 100644 index 0000000000..699ea498b0 --- /dev/null +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsMarketDao.kt @@ -0,0 +1,47 @@ +package one.mixin.android.db.perps + +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 + +@Dao +interface PerpsMarketDao : BaseDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(market: PerpsMarket) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(markets: List) + + @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") + suspend fun getMarket(marketId: String): PerpsMarket? + + @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") + suspend fun deleteAll() + + @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 new file mode 100644 index 0000000000..d7ca51f801 --- /dev/null +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionDao.kt @@ -0,0 +1,92 @@ +package one.mixin.android.db.perps + +import androidx.room.Dao +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 + +@Dao +interface PerpsPositionDao : BaseDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(position: PerpsPosition) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(positions: 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.market_id + 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 + + @Query( + """ + SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol + FROM positions p + 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 + """ + ) + fun observeOpenPositions(walletId: String): Flow> + + @Query(""" + SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol + FROM positions p + 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 + """) + fun getOpenPositionsPaged(walletId: String): DataSource.Factory + + @Query(""" + SELECT p.*, m.display_symbol, m.icon_url, m.token_symbol + FROM positions p + LEFT JOIN markets m ON m.market_id = p.market_id + WHERE p.position_id = :positionId + """) + 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.market_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' or state = 'opening')") + suspend fun deleteOpenByWallet(walletId: String) + + @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 = (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' 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' 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' or state = 'opening')") + 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 new file mode 100644 index 0000000000..021c606ca7 --- /dev/null +++ b/app/src/main/java/one/mixin/android/db/perps/PerpsPositionHistoryDao.kt @@ -0,0 +1,76 @@ +package one.mixin.android.db.perps + +import androidx.room.Dao +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 + +@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_histories h + LEFT JOIN markets m ON m.market_id = h.market_id + WHERE (:offset IS NULL OR h.closed_at < :offset) + ORDER BY h.closed_at DESC + LIMIT :limit + """) + suspend fun getHistories(limit: Int, offset: String? = null): List + + @Query( + """ + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + FROM position_histories h + LEFT JOIN markets m ON m.market_id = h.market_id + WHERE (:offset IS NULL OR h.closed_at < :offset) + ORDER BY h.closed_at DESC + LIMIT :limit + """ + ) + fun observeHistories(limit: Int, offset: String? = null): Flow> + + @Query(""" + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + FROM position_histories h + LEFT JOIN markets m ON m.market_id = h.market_id + ORDER BY h.closed_at DESC + """) + fun getHistoriesPaged(): DataSource.Factory + + @Query(""" + SELECT h.*, m.display_symbol, m.icon_url, m.token_symbol + 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_histories") + suspend fun deleteAll() + + @Query("SELECT MAX(closed_at) FROM position_histories") + suspend fun getLatestClosedAt(): String? + + @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_histories") + fun observeTotalRealizedPnl(): Flow + + @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_histories") + fun observeTotalClosedEntryValue(): Flow +} 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..7cb7528f2c --- /dev/null +++ b/app/src/main/java/one/mixin/android/di/PerpsModule.kt @@ -0,0 +1,42 @@ +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 one.mixin.android.db.perps.PerpsPositionHistoryDao +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 providePerpsPositionHistoryDao(database: PerpsDatabase): PerpsPositionHistoryDao { + return database.perpsPositionHistoryDao() + } + + @Provides + @Singleton + fun providePerpsMarketDao(database: PerpsDatabase): PerpsMarketDao { + return database.perpsMarketDao() + } +} 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..af4e8f5ab5 --- /dev/null +++ b/app/src/main/java/one/mixin/android/job/RefreshPerpsPositionsJob.kt @@ -0,0 +1,67 @@ +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 +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() + + if (walletId != null) { + 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) + } + } + } + + private suspend fun refreshPositions( + walletId: String, + positionDao: PerpsPositionDao, + ) { + try { + val response = routeService.getPerpsPositions(walletId = walletId) + + 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") + + 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}") + } + } catch (e: Exception) { + Timber.e(e, "RefreshPerpsPositionsJob: Exception occurred while fetching positions for wallet $walletId") + } + } +} 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/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/common/BottomSheetViewModel.kt b/app/src/main/java/one/mixin/android/ui/common/BottomSheetViewModel.kt index 9cf6f12aac..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,13 @@ 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() suspend fun kernelWithdrawalTransaction( @@ -2033,4 +2040,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/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/conversation/link/LinkBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/conversation/link/LinkBottomSheetDialogFragment.kt index f046d121ed..9ae4703d59 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,35 @@ class LinkBottomSheetDialogFragment : SchemeBottomSheet() { } private suspend fun handleTradeScheme(uri: Uri) { + val type = uri.getQueryParameter("type") + + if (type.equals("perps", true)) { + val marketId = uri.getQueryParameter("market") + if (marketId.isNullOrBlank() || !marketId.isUUID()) { + showError(R.string.Invalid_payment_link) + return + } + + val market = linkViewModel.getPerpsMarket(marketId) + if (market == null) { + showError(R.string.Data_error) + return + } + + PerpsActivity.showDetail( + requireContext(), + market.marketId, + market.displaySymbol, + market.displaySymbol, + market.tokenSymbol + ) + 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/MainActivity.kt b/app/src/main/java/one/mixin/android/ui/home/MainActivity.kt index 6f16017009..f5076dd4d7 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 @@ -347,27 +344,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() } } } @@ -1022,24 +1001,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) } } @@ -1115,6 +1089,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/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/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt new file mode 100644 index 0000000000..6309c54245 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -0,0 +1,701 @@ +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 +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 +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 +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.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 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 +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 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( + marketId: 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, lifecycleOwner) { + candles = emptyList() + isLoading = true + errorMessage = null + 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( + modifier = Modifier + .fillMaxSize() + .clipToBounds(), + 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 -> { + ScrollableCandleChart( + candles = candles, + context = context, + marketPrice = marketPrice?.toBigDecimalOrNull() + ) + } + } + } +} + +@Composable +private fun ScrollableCandleChart( + candles: List, + context: android.content.Context, + marketPrice: BigDecimal?, +) { + val candleView = candles.firstOrNull() ?: return + val items = candleView.items + if (items.isEmpty()) return + + 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() + } + + LaunchedEffect(items.size) { + if (items.size > 50) { + scrollState.scrollTo(scrollState.maxValue) + } + } + + val selectedIndex = touchXOnChart?.let { x -> + ((x - chartStartPaddingPx) / candleStepPx).toInt().coerceIn(0, items.lastIndex) + } + val selectedItem = selectedIndex?.let { index -> items.getOrNull(index) } + val latestPrice = marketPrice ?: items.lastOrNull()?.close?.toBigDecimalOrNull() + val axisPanelWidth = 52.dp + + Row(modifier = Modifier.fillMaxSize()) { + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .clipToBounds() + ) { + 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 + } + + val prices = mutableListOf() + visibleItems.forEach { item -> + 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) + 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, + 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 + 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 && !isPinching) + .clipToBounds() + ) { + 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, + crosshairLineColor = MixinAppTheme.colors.textAssist, + crosshairTagBackgroundColor = MixinAppTheme.colors.background, + crosshairTextColor = MixinAppTheme.colors.textPrimary, + ) + } + + BoxWithConstraints( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(axisPanelWidth) + .padding(start = 0.dp, top = 8.dp, bottom = 8.dp, end = 2.dp) + ) { + val containerHeight = maxHeight + val priceRange = maxPrice - minPrice + val currentPrice = latestPrice + val currentPriceRatio = if (priceRange > BigDecimal.ZERO && currentPrice != null) { + ((currentPrice - minPrice).toFloat() / priceRange.toFloat()).coerceIn(0f, 1f) + } else { + null + } + + if (!isTouching) { + Column( + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterEnd), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.End + ) { + Text( + 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 + .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) + ) + } + } + } + } + } + } + } +} + +@Composable +private fun PerpsCandleChartCanvas( + items: List, + timeFrame: String, + context: android.content.Context, + candleWidth: androidx.compose.ui.unit.Dp, + spacing: androidx.compose.ui.unit.Dp, + touchXOnChart: Float?, + scrollOffset: Float, + viewportWidth: Float, + maxPrice: BigDecimal, + minPrice: BigDecimal, + showCurrentPriceLine: Boolean, + currentPriceForLine: BigDecimal?, + currentPriceLineColor: Color, + crosshairLineColor: Color, + crosshairTagBackgroundColor: Color, + crosshairTextColor: Color, +) { + 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(8.dp + (candleWidth * items.size) + (spacing * (items.size - 1).coerceAtLeast(0))) + ) { + 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 viewportLeft = scrollOffset + val viewportRight = scrollOffset + viewportWidth + + val priceRange = maxPrice - minPrice + if (priceRange == BigDecimal.ZERO) return@Canvas + + 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 + if (x + candleWidthPx < viewportLeft || x - candleWidthPx > viewportRight) { + return@forEachIndexed + } + + 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 + ) + + 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) + ) + } + } + + 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() + 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, + timeText = formatCandleTime(item.timestamp, timeFrame), + paddingTop = paddingTop, + paddingBottom = paddingBottom, + paddingLeft = paddingLeft, + textMeasurer = textMeasurer, + viewportLeft = viewportLeft, + viewportRight = viewportRight, + lineColor = crosshairLineColor, + tagBackgroundColor = crosshairTagBackgroundColor, + textColor = crosshairTextColor, + ) + } + } + } + } +} + +private fun DrawScope.drawCurrentPriceLine( + price: BigDecimal, + minPrice: BigDecimal, + priceRange: BigDecimal, + paddingTop: Float, + chartHeight: Float, + paddingLeft: Float, + viewportLeft: Float, + viewportRight: Float, + paddingBottom: Float, + lineColor: Color, +) { + val y = (paddingTop + chartHeight - ((price - minPrice).toFloat() / priceRange.toFloat() * chartHeight)) + .coerceIn(paddingTop, size.height - paddingBottom) + + drawLine( + color = lineColor, + start = Offset(max(paddingLeft, viewportLeft), y), + end = Offset(min(size.width, viewportRight), y), + strokeWidth = 1f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f)) + ) +} + +private fun DrawScope.drawTouchCrosshair( + x: Float, + y: Float, + price: BigDecimal, + timeText: String, + paddingTop: Float, + paddingBottom: Float, + paddingLeft: Float, + textMeasurer: androidx.compose.ui.text.TextMeasurer, + viewportLeft: Float, + viewportRight: Float, + lineColor: Color, + tagBackgroundColor: Color, + textColor: Color, +) { + drawLine( + color = lineColor, + start = Offset(paddingLeft, y), + end = Offset(size.width, y), + strokeWidth = 1f + ) + + drawLine( + color = lineColor, + start = Offset(x, paddingTop), + end = Offset(x, size.height - paddingBottom), + strokeWidth = 1f + ) + + val timeTextLayout = textMeasurer.measure( + text = timeText, + style = TextStyle(fontSize = 11.sp, color = textColor) + ) + 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 = tagBackgroundColor, + topLeft = Offset(timeTagX, timeTagY), + size = Size(timeTagWidth, timeTagHeight), + cornerRadius = CornerRadius(12f, 12f) + ) + + drawText( + textLayoutResult = timeTextLayout, + topLeft = Offset(timeTagX + timeTagHorizontalPadding, timeTagY + timeTagVerticalPadding) + ) +} + +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) + } +} + +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()) { + "1m", "5m", "15m" -> "MM-dd HH:mm" + "1h", "4h" -> "MM-dd HH:mm" + "1d" -> "yyyy-MM-dd" + "1w" -> "yyyy-MM-dd" + else -> "MM-dd HH:mm" + } + + return runCatching { + zonedDateTime.format(DateTimeFormatter.ofPattern(pattern).withZone(localeZone)) + }.getOrDefault("--") +} + +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/ClosedPositionAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt new file mode 100644 index 0000000000..574d24dc56 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionAdapter.kt @@ -0,0 +1,154 @@ +package one.mixin.android.ui.home.web3.trade + +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.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.extension.priceFormat +import one.mixin.android.ui.common.recyclerview.SafePagedListAdapter +import one.mixin.android.vo.Fiats +import java.math.BigDecimal + +class ClosedPositionAdapter( + private val isQuoteColorReversed: Boolean = false, + private val onItemClick: ((PerpsPositionHistoryItem) -> Unit)? = null +) : SafePagedListAdapter(DiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + ItemClosedPositionListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + isQuoteColorReversed, + onItemClick + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = getItem(position) ?: return + holder.bind(position = item, positionInList = position, listSize = itemCount) + } + + class ViewHolder( + private val binding: ItemClosedPositionListBinding, + private val isQuoteColorReversed: Boolean, + private val onItemClick: ((PerpsPositionHistoryItem) -> Unit)? + ) : RecyclerView.ViewHolder(binding.root) { + + 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 isLong = position.side.equals("long", ignoreCase = true) + val sideText = if (isLong) { + context.getString(R.string.Long) + } 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 = 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 ?: ""}" + + val pnl = position.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO + rightTopValueTv.text = formatSignedUsd(pnl) + rightTopValueTv.setTextColor( + when { + pnl > BigDecimal.ZERO -> { + context.getColor(if (isQuoteColorReversed) R.color.wallet_red else R.color.wallet_green) + } + + pnl < BigDecimal.ZERO -> { + context.getColor(if (isQuoteColorReversed) R.color.wallet_green else R.color.wallet_red) + } + + else -> { + resolveAttrColor(root, R.attr.text_primary) + } + } + ) + rightBottomValueTv.isVisible = false + } + } + + 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 -> "+$fiatSymbol$fiatAmount" + amount < BigDecimal.ZERO -> "-$fiatSymbol$fiatAmount" + else -> "$fiatSymbol${BigDecimal.ZERO.multiply(fiatRate).priceFormat()}" + } + } + + 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 + } + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: PerpsPositionHistoryItem, + newItem: PerpsPositionHistoryItem + ): Boolean { + return oldItem.historyId == newItem.historyId + } + + override fun areContentsTheSame( + 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 new file mode 100644 index 0000000000..b7ad612b08 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -0,0 +1,159 @@ +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 +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +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.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( + position: PerpsPositionHistoryItem, + onClick: () -> Unit = {}, +) { + 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) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isProfit = pnl >= BigDecimal.ZERO + 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.tokenSymbol ?: "Unknown" + val quantity = try { + val qty = BigDecimal(position.quantity) + String.format("%f", qty) + } catch (e: Exception) { + 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() + .clickable(onClick = onClick) + .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.lowercase() == "long") { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + Text( + text = sideText, + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = displaySymbol, + fontSize = 16.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( + text = "${(quantity.toBigDecimalOrNull()?: BigDecimal.ZERO).abs().stripTrailingZeros().toPlainString()} ${position.tokenSymbol ?: ""}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = "${if (isProfit) "+" else "-"}$fiatSymbol${pnl.abs().multiply(fiatRate).priceFormat()}", + fontSize = 16.sp, + color = pnlColor + ) + } + } +} 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..228091a9d2 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt @@ -0,0 +1,78 @@ +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( + hideGuide: Boolean = false, + 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 + ) + + if (!hideGuide){ + HelpOption( + title = stringResource(R.string.Trading_Guide), + onClick = onTradingGuide + ) + } + + 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/InputTextField.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/InputTextField.kt index 1899ae1d50..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 @@ -41,6 +41,8 @@ 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.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.request.ImageRequest @@ -62,6 +64,9 @@ fun InputContent( onInputChanged: ((String) -> Unit)? = null, 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()) { @@ -77,14 +82,14 @@ 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, ) } } } - Right(token, selectClick) + Right(token, selectClick, tokenIconSize) } Text(text = "", style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Light)) // placeholder } @@ -144,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), @@ -164,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) ) } @@ -177,7 +182,7 @@ fun InputContent( } } } - Right(token, selectClick) + Right(token, selectClick, tokenIconSize) } Text(text = "", style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Light)) // placeholder } @@ -188,6 +193,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 +201,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 +209,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 +218,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/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/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/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) { 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..4920e9582c --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TotalPositionValueAdapter.kt @@ -0,0 +1,159 @@ +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.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 +import one.mixin.android.vo.Fiats +import java.math.BigDecimal + +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 + private var isClosed: Boolean = false + @StringRes + private var titleResId: Int = R.string.Total_Position_Value + + fun submitTotal(value: BigDecimal) { + totalValue = value + notifyItemChanged(0) + } + + fun submitSubtitle(value: BigDecimal, percent: BigDecimal?) { + subValue = value + subPercent = percent + notifyItemChanged(0) + } + + fun submitTitle(@StringRes titleResId: Int) { + this.titleResId = titleResId + this.isClosed = (titleResId == R.string.Total_Realized_PnL) + 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, isQuoteColorReversed) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(totalValue, subValue, subPercent, titleResId, isClosed) + } + + override fun getItemCount(): Int = 1 + + class ViewHolder( + itemView: View, + private val isQuoteColorReversed: Boolean, + ) : RecyclerView.ViewHolder(itemView) { + private val titleTv: TextView = itemView.findViewById(R.id.title_tv) + private val valueTv: TextView = itemView.findViewById(R.id.value_tv) + private val subtitleTv: TextView = itemView.findViewById(R.id.subtitle_tv) + + fun bind( + total: BigDecimal, + subtitleValue: BigDecimal, + subtitlePercent: BigDecimal?, + @StringRes titleResId: Int, + isClosed: Boolean + ) { + val context = itemView.context + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + titleTv.text = context.getString(titleResId) + val gainColor = context.getColor( + if (isQuoteColorReversed) R.color.wallet_red else R.color.wallet_green, + ) + val lossColor = context.getColor( + if (isQuoteColorReversed) R.color.wallet_green else R.color.wallet_red, + ) + + if (isClosed) { + valueTv.text = "${ + if (total >= BigDecimal.ZERO) { + "+" + } else { + "-" + } + }$fiatSymbol${total.abs().multiply(fiatRate).priceFormat()}" + val isProfit = subtitleValue >= BigDecimal.ZERO + valueTv.setTextColor( + if (isProfit) { + gainColor + } else { + lossColor + } + ) + 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( + if (isProfit) { + gainColor + } else { + lossColor + } + ) + } + } else { + valueTv.text = "$fiatSymbol${total.abs().multiply(fiatRate).priceFormat()}" + valueTv.setTextColor(resolveAttrColor(itemView, R.attr.text_primary)) + 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) + } + ) + } + } + } + + 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" + } + } + + 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 a550fe42e5..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 @@ -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.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 import one.mixin.android.ui.wallet.AllOrdersFragment import one.mixin.android.ui.wallet.DepositFragment import one.mixin.android.ui.wallet.LimitTransferBottomSheetDialogFragment @@ -160,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, @@ -234,18 +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)) } - - if (!isLimitOrderTabBadgeDismissed) { - isLimitOrderTabBadgeDismissed = true - defaultSharedPreferences.putBoolean(Account.PREF_TRADE_LIMIT_ORDER_BADGE_DISMISSED, true) + var isPerpetualTabBadgeDismissed by remember(currentWalletId) { + mutableStateOf(defaultSharedPreferences.getBoolean(perpetualBadgePrefKey, false)) } + var isPerpetualOrderBadgeDismissed by remember(currentWalletId) { + mutableStateOf(defaultSharedPreferences.getBoolean(perpetualOrderBadgePrefKey, false)) + } + TradePage( walletId = walletId, swapFrom = fromToken, @@ -255,6 +284,8 @@ class TradeFragment : BaseFragment() { inMixin = inMixin(), orderBadge = orderBadge, isLimitOrderTabBadgeDismissed = isLimitOrderTabBadgeDismissed, + isPerpetualTabBadgeDismissed = isPerpetualTabBadgeDismissed, + isPerpetualOrderBadgeDismissed = isPerpetualOrderBadgeDismissed, initialAmount = initialAmount, lastOrderTime = lastOrderTime, reviewing = reviewing, @@ -270,7 +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(perpetualBadgePrefKey, true) + } + }, + onDismissPerpetualOrderBadge = { + if (!isPerpetualOrderBadgeDismissed) { + isPerpetualOrderBadgeDismissed = true + defaultSharedPreferences.putBoolean(perpetualOrderBadgePrefKey, true) } }, onTabChanged = { index -> @@ -326,8 +369,67 @@ class TradeFragment : BaseFragment() { this@apply.hideKeyboard() navTo(OrderDetailFragment.newInstance(orderId), OrderDetailFragment.TAG) }, + onShowTradingGuide = { tabIndex -> + this@apply.hideKeyboard() + 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) + } + } + }, + onShowHelpBottomSheet = { onContactSupport, onTradingGuide -> + this@apply.hideKeyboard() + TradeHelpBottomSheetDialogFragment.newInstance().apply { + this.onContactSupport = onContactSupport + this.onTradingGuide = onTradingGuide + }.show(parentFragmentManager, TradeHelpBottomSheetDialogFragment.TAG) + }, pop = { navigateUp(navController) + }, + onShowMarketList = { isLong -> + PerpsMarketListBottomSheetDialogFragment.newInstance(isLong).show(parentFragmentManager, PerpsMarketListBottomSheetDialogFragment.TAG) + }, + onShowAllMarkets = { + navTo(AllPerpsMarketsFragment.newInstance(), AllPerpsMarketsFragment.TAG) + }, + onShowAllOpenPositions = { + navTo(AllPositionsFragment.newOpenInstance(), AllPositionsFragment.TAG) + }, + onShowAllClosedPositions = { + navTo(AllPositionsFragment.newClosedInstance(), AllPositionsFragment.TAG) + }, + onOpenPositionClick = { position -> + navTo(PositionDetailFragment.newInstance(position), PositionDetailFragment.TAG) + }, + onMarketItemClick = { market -> + PerpsActivity.showDetail( + requireContext(), + market.marketId, + market.displaySymbol, + 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/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 b998854b73..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 @@ -44,10 +44,14 @@ 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 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 @@ -55,6 +59,8 @@ 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.ui.home.web3.trade.perps.PerpetualViewModel import one.mixin.android.vo.WalletCategory import java.math.BigDecimal @@ -70,6 +76,8 @@ fun TradePage( inMixin: Boolean, orderBadge: Boolean, isLimitOrderTabBadgeDismissed: Boolean, + isPerpetualTabBadgeDismissed: Boolean, + isPerpetualOrderBadgeDismissed: Boolean, initialAmount: String?, lastOrderTime: Long?, reviewing: Boolean, @@ -81,19 +89,40 @@ fun TradePage( onDeposit: (SwapToken) -> Unit, onOrderList: (String, Boolean) -> Unit, onDismissLimitOrderTabBadge: () -> Unit, + onDismissPerpetualTabBadge: () -> Unit, + onDismissPerpetualOrderBadge: () -> Unit, onTabChanged: (Int) -> Unit, onSwitchToLimitOrder: (String, SwapToken, SwapToken) -> Unit, pop: () -> Unit, onLimitOrderClick: (String) -> Unit, + onShowTradingGuide: (Int) -> Unit, + onShowHelpBottomSheet: (() -> Unit, () -> Unit) -> Unit, + onShowMarketList: (Boolean) -> Unit, + onShowAllMarkets: () -> Unit, + onShowAllOpenPositions: () -> Unit, + onShowAllClosedPositions: () -> Unit, + onOpenPositionClick: (PerpsPositionItem) -> Unit, + onMarketItemClick: (PerpsMarket) -> Unit, + onClosedPositionClick: (PerpsPositionHistoryItem) -> Unit, ) { val context = LocalContext.current val viewModel = hiltViewModel() + val perpsViewModel = hiltViewModel() var walletDisplayName by remember { mutableStateOf(null) } var pendingOrderCount by remember { mutableIntStateOf(0) } + val coroutineScope = rememberCoroutineScope() 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 @@ -112,52 +141,82 @@ fun TradePage( } } - val coroutineScope = rememberCoroutineScope() + // Build tabs dynamically: Perpetual tab should only exist when walletId == null + val switchToLimitRequested = remember { mutableStateOf(false) } + + 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, + ) + } + + var perpetualTabIndex: Int? = null + if (walletId == null) { + perpetualTabIndex = tabs.size + tabs += TabItem(title = stringResource(R.string.Perpetual)) { + PerpetualContent( + onShowTradingGuide = { onShowTradingGuide(perpetualTabIndex ?: 0) }, + onShowMarketList = onShowMarketList, + onShowAllMarkets = onShowAllMarkets, + onShowAllOpenPositions = onShowAllOpenPositions, + onShowAllClosedPositions = onShowAllClosedPositions, + onOpenPositionClick = onOpenPositionClick, + onMarketItemClick = onMarketItemClick, + onClosedPositionClick = onClosedPositionClick, + ) + } + } + + val tabCount = tabs.size - val tabCount = 2 val pagerState = rememberPagerState( - initialPage = initialTabIndex.coerceIn(0, tabCount - 1), + initialPage = initialTabIndex.coerceIn(0, (tabCount - 1).coerceAtLeast(0)), pageCount = { tabCount }, ) - 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, - ) + // 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 } - ) + } PageScaffold( title = stringResource(id = R.string.Trade), @@ -190,9 +249,17 @@ fun TradePage( verticalScrollable = true, pop = pop, actions = { + val isPerpetualOrderEntry = perpetualTabIndex != null && pagerState.currentPage == perpetualTabIndex Box { IconButton(onClick = { - onOrderList(currentWalletId, false) + if (isPerpetualOrderEntry) { + if (!isPerpetualOrderBadgeDismissed) { + onDismissPerpetualOrderBadge() + } + onShowAllOpenPositions() + } else { + onOrderList(currentWalletId, false) + } }) { Icon( painter = painterResource(id = R.drawable.ic_order), @@ -200,7 +267,7 @@ fun TradePage( tint = MixinAppTheme.colors.icon, ) } - if (pendingOrderCount > 0) { + if (!isPerpetualOrderEntry && pendingOrderCount > 0) { Box( modifier = Modifier .offset(x = (-8).dp, y = (8).dp) @@ -216,7 +283,34 @@ fun TradePage( color = Color.White, ) } - } else if (orderBadge) { + } 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 + .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) @@ -230,7 +324,10 @@ fun TradePage( } } IconButton(onClick = { - context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + onShowHelpBottomSheet( + { context.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) }, + { onShowTradingGuide(pagerState.currentPage) } + ) }) { Icon( painter = painterResource(id = R.drawable.ic_support), @@ -250,10 +347,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) @@ -261,6 +360,10 @@ fun TradePage( if (isAdvancedTab) { onDismissLimitOrderTabBadge() } + if (isPerpetualTab && !isPerpetualTabBadgeDismissed) { + onDismissPerpetualTabBadge() + onShowTradingGuide(index) + } onTabChanged(index) }, ) 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 new file mode 100644 index 0000000000..8cfdd3d416 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPerpsMarketsFragment.kt @@ -0,0 +1,179 @@ +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 +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +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.ui.common.BaseFragment + +@AndroidEntryPoint +class AllPerpsMarketsFragment : BaseFragment() { + + companion object { + const val TAG = "AllPerpsMarketsFragment" + + fun newInstance() = AllPerpsMarketsFragment() + } + + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(inflater.context).apply { + setContent { + MixinAppTheme(darkTheme = context.isNightMode()) { + AllMarketsPage( + pop = { activity?.onBackPressedDispatcher?.onBackPressed() }, + onMarketClick = { market -> + lifecycleScope.launch { + showMarketDetails(market) + } + } + ) + } + } + } + } + + private fun showMarketDetails(market: PerpsMarket) { + if (activity != null) { + PerpsActivity.showDetail( + requireContext(), + market.marketId, + market.displaySymbol, + market.displaySymbol, + market.tokenSymbol + ) + } + } +} + +@Composable +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 = 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 = stringResource(R.string.perps_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 = 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()) { + PerpsMarketItem( + market = market, + quoteColorReversed = quoteColorReversed, + 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/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt new file mode 100644 index 0000000000..1f60daa753 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -0,0 +1,325 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LiveData +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.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.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 +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 +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) { + + companion object { + const val TAG = "AllPositionsFragment" + 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 = 3_000L + private const val CLOSED_POSITION_REFRESH_LIMIT = 100 + + fun newInstance(showOpenPositions: Boolean = false) = AllPositionsFragment().apply { + arguments = Bundle().apply { + putString(ARGS_POSITION_TYPE, if (showOpenPositions) TYPE_OPEN else TYPE_CLOSED) + } + } + + fun newOpenInstance() = newInstance(showOpenPositions = true) + + fun newClosedInstance() = newInstance(showOpenPositions = false) + } + + @Inject + lateinit var perpsMarketDao: PerpsMarketDao + + private val binding by viewBinding(FragmentAllClosedPositionsBinding::bind) + private val viewModel by viewModels() + private val totalValueAdapter by lazy { TotalPositionValueAdapter(isQuoteColorReversed) } + private val isQuoteColorReversed by lazy { + requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + } + + private val openPositionAdapter by lazy { + OpenPositionAdapter(isQuoteColorReversed) { position -> + lifecycleScope.launch { + val market = withContext(Dispatchers.IO) { + perpsMarketDao.getMarket(position.marketId) + } + activity?.let { ctx -> + PerpsActivity.showDetail( + context = ctx, + marketId = position.marketId, + marketSymbol = market?.displaySymbol ?: "", + marketDisplaySymbol = market?.displaySymbol ?: "", + marketTokenSymbol = market?.tokenSymbol ?: "" + ) + } + } + } + } + + private val closedPositionAdapter by lazy { + ClosedPositionAdapter(isQuoteColorReversed) { position -> + activity?.supportFragmentManager?.let { fm -> + fm.beginTransaction() + .add( + android.R.id.content, + PositionDetailFragment.Companion.newInstance(position), + PositionDetailFragment.Companion.TAG + ) + .addToBackStack(null) + .commit() + } + } + } + + private enum class PositionType { + OPEN, + CLOSED + } + + private var positionType: PositionType = PositionType.CLOSED + 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 + private var lastClosedTotalPnl: Double = 0.0 + private var lastClosedTotalEntryValue: Double = 0.0 + + private val openPositionsObserver = Observer> { pagedList -> + binding.progressBar.isVisible = false + openPositionAdapter.submitList(pagedList) + val isEmpty = pagedList.isEmpty() + binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Position) + binding.emptyView.helpAction.isVisible = isEmpty + binding.emptyView.root.isVisible = isEmpty + } + + private val closedPositionsObserver = Observer> { pagedList -> + binding.progressBar.isVisible = false + closedPositionAdapter.submitList(pagedList) + val isEmpty = pagedList.isEmpty() + binding.emptyView.walletTransactionsEmpty.text = getString(R.string.No_Activity) + binding.emptyView.helpAction.isVisible = false + binding.emptyView.root.isVisible = isEmpty + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.apply { + 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) + } + 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), + "" + ) + + positionsRv.layoutManager = LinearLayoutManager(requireContext()) + emptyView.helpAction.setOnClickListener { + PerpetualGuideBottomSheetDialogFragment.newInstance( + initialTab = PerpetualGuideBottomSheetDialogFragment.TAB_OVERVIEW + ).show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + } + } + + loadPositions() + observeOpenPositionCountChanges() + observePeriodicRefresh() + } + + private fun loadPositions() { + openPositionsLiveData?.removeObservers(viewLifecycleOwner) + closedPositionsLiveData?.removeObservers(viewLifecycleOwner) + totalValueJob?.cancel() + + lastOpenTotalValue = 0.0 + lastOpenTotalPnl = 0.0 + lastClosedTotalPnl = 0.0 + lastClosedTotalEntryValue = 0.0 + previousOpenPositionsCount = null + + 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.perps_activity), "") + totalValueAdapter.submitTitle(R.string.Total_Realized_PnL) + binding.positionsRv.adapter = ConcatAdapter(totalValueAdapter, closedPositionAdapter) + loadClosedPositions() + } + } + + private fun loadOpenPositions() { + val walletId = Session.getAccountId() ?: run { + binding.progressBar.isVisible = false + return + } + + binding.progressBar.isVisible = true + binding.emptyView.root.isVisible = false + binding.emptyView.helpAction.isVisible = false + totalValueAdapter.submitTotal(BigDecimal.ZERO) + totalValueAdapter.submitSubtitle(BigDecimal.ZERO, BigDecimal.ZERO) + + openPositionsLiveData = viewModel.getOpenPositionsPaged(walletId) + openPositionsLiveData?.observe(viewLifecycleOwner, openPositionsObserver) + observeOpenTotals(walletId) + } + + private fun loadClosedPositions() { + val walletId = Session.getAccountId() ?: run { + binding.progressBar.isVisible = false + return + } + + binding.progressBar.isVisible = true + binding.emptyView.root.isVisible = false + binding.emptyView.helpAction.isVisible = false + totalValueAdapter.submitTotal(BigDecimal.ZERO) + totalValueAdapter.submitSubtitle(BigDecimal.ZERO, BigDecimal.ZERO) + + 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) -> + 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)) + } + } + } + } + } + + 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) -> + if (lastClosedTotalPnl != totalPnl || lastClosedTotalEntryValue != totalEntryValue) { + lastClosedTotalPnl = totalPnl + lastClosedTotalEntryValue = totalEntryValue + val nowValue = totalEntryValue + totalPnl + val percent = calculateClosedPercent(nowValue, 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 + viewModel.refreshPositionHistory( + walletId = walletId, + limit = CLOSED_POSITION_REFRESH_LIMIT + ) + while (isActive) { + viewModel.refreshPositions(walletId) + 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 + } + return value / base * 100 + } + + private fun calculateClosedPercent(nowValue: Double, entryValue: Double): Double { + if (entryValue == 0.0) { + return 0.0 + } + return (nowValue / entryValue - 1.0) * 100 + } +} 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 new file mode 100644 index 0000000000..c2aa90f498 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/LeverageBottomSheetDialogFragment.kt @@ -0,0 +1,360 @@ +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.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.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.extension.screenHeight +import one.mixin.android.ui.common.MixinComposeBottomSheetDialogFragment +import one.mixin.android.ui.wallet.alert.components.cardBackground +import one.mixin.android.util.SystemUIManager +import java.math.BigDecimal +import kotlin.math.abs + +@AndroidEntryPoint +class LeverageBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + + 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 + } + + 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, 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), + ) + } + } + + override fun dismiss() { + dismissAllowingStateLoss() + } +} + +@Composable +private fun LeverageContent( + currentLeverage: Float, + maxLeverage: Int, + amount: String, + isLong: Boolean, + onCancel: () -> Unit, + onApply: (Float) -> Unit +) { + val boundedMaxLeverage = maxLeverage.coerceAtLeast(1) + var tempLeverage by remember(currentLeverage, boundedMaxLeverage) { + mutableFloatStateOf(currentLeverage.coerceIn(1f, boundedMaxLeverage.toFloat())) + } + + 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) + ) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "${tempLeverage.toInt()}x", + fontSize = 48.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..boundedMaxLeverage.toFloat(), + steps = (boundedMaxLeverage - 2).coerceAtLeast(0), + colors = SliderDefaults.colors( + thumbColor = MixinAppTheme.colors.accent, + activeTrackColor = MixinAppTheme.colors.accent, + inactiveTrackColor = MixinAppTheme.colors.backgroundWindow, + activeTickColor = Color.Transparent, + inactiveTickColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + 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, + color = MixinAppTheme.colors.textAssist + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + ProfitLossInfo( + amount = amount, + leverage = tempLeverage, + isLong = isLong + ) + + Spacer(modifier = Modifier.height(48.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)) + } +} + +@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.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.textAssist + ) + } + 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.textAssist + ) + 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.textAssist + ) + } +} 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 new file mode 100644 index 0000000000..0f9b358c9f --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionAdapter.kt @@ -0,0 +1,166 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.AttrRes +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +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.extension.priceFormat +import one.mixin.android.ui.common.recyclerview.SafePagedListAdapter +import one.mixin.android.vo.Fiats +import java.math.BigDecimal + +class OpenPositionAdapter( + private val isQuoteColorReversed: Boolean = false, + private val onItemClick: ((PerpsPositionItem) -> Unit)? = null +) : SafePagedListAdapter(DiffCallback()) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + ItemClosedPositionListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + isQuoteColorReversed, + onItemClick + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = getItem(position) ?: return + holder.bind(position = item, positionInList = position, listSize = itemCount) + } + + class ViewHolder( + private val binding: ItemClosedPositionListBinding, + private val isQuoteColorReversed: Boolean, + private val onItemClick: ((PerpsPositionItem) -> Unit)? + ) : RecyclerView.ViewHolder(binding.root) { + + 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 isLong = position.side.equals("long", ignoreCase = true) + val sideText = if (isLong) { + context.getString(R.string.Long) + } 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 = 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 quantity = position.quantity.toBigDecimalOrNull() + 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(positionValue) + rightTopValueTv.setTextColor(resolveAttrColor(root, R.attr.text_primary)) + + rightBottomValueTv.isVisible = true + val pnl = (position.unrealizedPnl ?: "0").toBigDecimalOrNull() ?: BigDecimal.ZERO + rightBottomValueTv.text = formatSignedUsd(pnl) + rightBottomValueTv.setTextColor( + when { + pnl > BigDecimal.ZERO -> { + context.getColor(if (isQuoteColorReversed) R.color.wallet_red else R.color.wallet_green) + } + + pnl < BigDecimal.ZERO -> { + context.getColor(if (isQuoteColorReversed) R.color.wallet_green else R.color.wallet_red) + } + + else -> { + resolveAttrColor(root, R.attr.text_primary) + } + } + ) + } + } + + private fun formatUsd(amount: BigDecimal): String { + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + return "$fiatSymbol${amount.multiply(fiatRate).priceFormat()}" + } + + 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 -> "+$fiatSymbol$fiatAmount" + amount < BigDecimal.ZERO -> "-$fiatSymbol$fiatAmount" + else -> "$fiatSymbol${BigDecimal.ZERO.multiply(fiatRate).priceFormat()}" + } + } + + private fun resolveAttrColor(view: View, @AttrRes attr: Int): Int { + val typedValue = TypedValue() + view.context.theme.resolveAttribute(attr, typedValue, true) + return if (typedValue.resourceId != 0) { + view.context.getColor(typedValue.resourceId) + } else { + typedValue.data + } + } + } + + 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/perps/OpenPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt new file mode 100644 index 0000000000..dd4ee4bc0b --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionItem.kt @@ -0,0 +1,148 @@ +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 +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +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.extension.priceFormat +import one.mixin.android.vo.Fiats +import java.math.BigDecimal + +@Composable +fun OpenPositionItem( + position: PerpsPositionItem, + onClick: () -> Unit = {}, +) { + val context = LocalContext.current + val quoteColorPref = context.defaultSharedPreferences + .getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + 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() + + 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) { + 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() + .clickable(onClick = onClick) + .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 (isLong) { + stringResource(R.string.Long) + } else { + stringResource(R.string.Short) + } + Text( + text = sideText, + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = displaySymbol, + fontSize = 16.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( + text = "$quantity ${position.tokenSymbol ?: ""}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = "${fiatSymbol}${positionValue.multiply(fiatRate).priceFormat()}", + fontSize = 16.sp, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(2.dp)) + 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 { + if (quoteColorPref) { + MixinAppTheme.colors.walletGreen + } else { + MixinAppTheme.colors.walletRed + } + } + Text( + text = "${if (unrealizedPnl >= BigDecimal.ZERO) "+" else "-"}$fiatSymbol${unrealizedPnl.abs().multiply(fiatRate).priceFormat()}", + 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 new file mode 100644 index 0000000000..4bad63b7df --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/OpenPositionPage.kt @@ -0,0 +1,772 @@ +package one.mixin.android.ui.home.web3.trade.perps + +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 +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.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.IconButton +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.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.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.delay +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.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 +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 +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( + market: PerpsMarket, + isLong: Boolean, + onBack: () -> Unit, + onOpenSuccess: (String) -> Unit = { onBack() }, + selectedToken: TokenItem?, + onTokenSelect: () -> Unit = {}, + onCurrentTokenChange: (TokenItem?) -> 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()) + .orEmpty() + .filter { it.isNotBlank() } + .toSet() + } + + var currentMarket by remember(marketId) { mutableStateOf(market) } + 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 = remember(marketId) { + context.defaultSharedPreferences + .getInt(getLeveragePrefKey(marketId), 1) + .coerceAtLeast(1) + } + var leverage by remember(marketId) { mutableFloatStateOf(savedLeverage.toFloat()) } + + LaunchedEffect(marketId) { + while (true) { + viewModel.loadMarketDetail( + marketId = marketId, + onSuccess = { data -> + currentMarket = data + }, + onError = {} + ) + delay(MARKET_REFRESH_INTERVAL_MS) + } + } + + LaunchedEffect(acceptedPerpAssetIds) { + viewModel.loadUsdTokens { tokens -> + 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 } + } ?: supportedTokens.firstOrNull() + } + } + + LaunchedEffect(selectedToken?.assetId, availableTokens) { + selectedToken?.let { target -> + 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 + } + LaunchedEffect(maxLeverage, marketId) { + val boundedLeverage = leverage.coerceIn(1f, maxLeverage.toFloat()) + if (boundedLeverage != leverage) { + leverage = boundedLeverage + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), boundedLeverage.toInt()) + } + } + + val leverageOptions = generateLeverageOptions(maxLeverage) + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + val inputAmount = usdtAmount.toBigDecimalOrNull() + val tokenBalance = currentToken?.balance?.toBigDecimalOrNull() ?: BigDecimal.ZERO + val minimumMargin = currentMarket.minAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO + val maximumMargin = currentMarket.maxAmount.toBigDecimalOrNull() ?: BigDecimal.ZERO + val hasInputAmount = inputAmount != null && inputAmount > BigDecimal.ZERO + val belowMinimumMargin = hasInputAmount && minimumMargin > BigDecimal.ZERO && inputAmount < minimumMargin + val aboveMaximumMargin = hasInputAmount && maximumMargin > BigDecimal.ZERO && inputAmount > maximumMargin + val insufficientBalance = hasInputAmount && inputAmount > tokenBalance + val showAddAction = insufficientBalance || tokenBalance <= BigDecimal.ZERO + val canReview = hasInputAmount && !belowMinimumMargin && !aboveMaximumMargin && !insufficientBalance + val marginLimitError = when { + belowMinimumMargin -> stringResource( + R.string.perps_minimum_margin, + minimumMargin.stripTrailingZeros().toPlainString() + ) + aboveMaximumMargin -> stringResource( + R.string.perps_maximum_margin, + maximumMargin.stripTrailingZeros().toPlainString() + ) + else -> null + } + val displayedErrorInfo = errorInfo?.takeIf { it.isNotBlank() } ?: marginLimitError + val tokenNetworkName = currentToken?.chainName + ?.takeIf { it.isNotBlank() } + ?: currentToken?.chainSymbol + ?.takeIf { it.isNotBlank() } + ?: "" + + MixinAppTheme { + PageScaffold( + title = stringResource(R.string.Open_Position), + verticalScrollable = false, + 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 + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + 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.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + 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)) + + InputContent( + token = currentToken?.toSwapToken(), + text = usdtAmount, + selectClick = { + onTokenSelect() + }, + onInputChanged = { usdtAmount = it }, + tokenIconSize = 25.dp, + inputFontSize = 28.sp, + inputFontWeight = FontWeight.W500, + ) + + 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 = currentToken?.balance?.numberFormat8() ?: "0", + style = TextStyle( + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + textAlign = TextAlign.Start, + ), + modifier = Modifier.clickable { + usdtAmount = currentToken?.balance ?: "0" + } + ) + if (showAddAction) { + 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 = currentToken + if (token == null) { + onTokenSelect() + 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.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)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + + + Text( + text = stringResource(R.string.Leverage), + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + + + Spacer(modifier = Modifier.height(12.dp)) + + 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 + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), newLeverage.toInt()) + }.show(activity.supportFragmentManager, LeverageBottomSheetDialogFragment.TAG) + }, + text = "${leverage.toInt()}x", + fontSize = 28.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + 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 + } + + val displayText = when (lev) { + -1 -> stringResource(R.string.Custom) + maxLeverage.takeIf { it > 1 } -> stringResource(R.string.Max) + else -> "${lev}x" + } + + 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.borderColor, + 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 + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), newLeverage.toInt()) + }.show(activity.supportFragmentManager, LeverageBottomSheetDialogFragment.TAG) + } else { + leverage = lev.toFloat() + context.defaultSharedPreferences.putInt(getLeveragePrefKey(marketId), lev) + } + }, + contentAlignment = Alignment.Center + ) { + 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 + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + + val profitInfo = calculateProfitInfo( + amount = usdtAmount, + leverage = leverage, + isLong = isLong, + priceChangePercent = 1.0, + fiatRate = fiatRate, + fiatSymbol = fiatSymbol, + ) + + Text( + text = profitInfo, + fontSize = 13.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.padding(horizontal = 4.dp) + ) + + } + + Spacer(modifier = Modifier.height(16.dp)) + + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row (verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.position_size), + 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 + PerpetualGuideBottomSheetDialogFragment.newInstance(PerpetualGuideBottomSheetDialogFragment.TAB_POSITION) + .show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } + Text( + text = "${calculateOrderValue(usdtAmount, leverage, currentMarket.markPrice)} ${currentMarket.tokenSymbol}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Row (verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Liquidation_Price), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + Text( + text = calculateLiquidationPrice( + currentMarket.markPrice, + leverage, + isLong, + fiatRate, + fiatSymbol, + ), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(16.dp)) + + if (displayedErrorInfo != null) { + Text( + text = displayedErrorInfo, + 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( + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + .height(48.dp), + onClick = { + val token = currentToken ?: return@Button + val amount = usdtAmount.toBigDecimalOrNull() ?: return@Button + + if (amount <= BigDecimal.ZERO) return@Button + if (minimumMargin > BigDecimal.ZERO && amount < minimumMargin) { + errorInfo = context.getString( + R.string.perps_minimum_margin, + minimumMargin.stripTrailingZeros().toPlainString() + ) + return@Button + } + if (maximumMargin > BigDecimal.ZERO && amount > maximumMargin) { + errorInfo = context.getString( + R.string.perps_maximum_margin, + maximumMargin.stripTrailingZeros().toPlainString() + ) + return@Button + } + if (amount > (token.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO)) return@Button + + val m = currentMarket + 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 + + + scope.launch { + val hasOpeningPosition = viewModel.getOpenPositionsFromDb(walletId) + .any { it.marketId == m.marketId } + if (hasOpeningPosition) { + errorInfo = context.getString(R.string.error_waiting_other_orders) + return@launch + } + + viewModel.openPerpsOrder( + assetId = token.assetId, + marketId = m.marketId, + side = if (isLong) "long" else "short", + amount = amount.stripTrailingZeros().toPlainString(), + leverage = leverage.toInt(), + walletId = walletId, + 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.paymentUrl + ).setOnDone { + onOpenSuccess(m.marketId) + }.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) } + } + } + ) + } + }, + enabled = canReview, + colors = ButtonDefaults.outlinedButtonColors( + backgroundColor = if (canReview) { + 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 = if (insufficientBalance) { + "${currentToken?.symbol ?: ""} ${stringResource(R.string.insufficient_balance)}" + } else { + stringResource(R.string.Review) + }, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = if (canReview) { + Color.White + } else { + MixinAppTheme.colors.textAssist + } + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + +} + +private fun generateLeverageOptions(maxLeverage: Int): List { + val safeMaxLeverage = maxLeverage.coerceAtLeast(1) + val baseOptions = listOf(2, 5, 10, 20) + val options = baseOptions + .filter { it in 1 until safeMaxLeverage } + .toMutableList() + + options.add(safeMaxLeverage) + options.add(-1) + + return options.distinct() +} + +@Composable +private fun calculateProfitInfo( + amount: String, + 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", "${fiatSymbol}0.00") + } else { + stringResource(R.string.Price_Down_Profit, "1", "0.0", "${fiatSymbol}0.00") + } + } + + val profitPercent = priceChangePercent * leverage + 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), + "${fiatSymbol}${profitAmount.priceFormat()}" + ) + } else { + stringResource( + R.string.Price_Down_Profit, + String.format("%.0f", abs(priceChangePercent)), + String.format("%.1f", profitPercent), + "${fiatSymbol}${profitAmount.priceFormat()}" + ) + } +} + +private fun calculateOrderValue(amount: String, leverage: Float, price: String): String { + val amountValue = amount.toBigDecimalOrNull() ?: BigDecimal.ZERO + val priceValue = price.toBigDecimalOrNull() ?: BigDecimal.ZERO + + + if (priceValue == BigDecimal.ZERO) { + return "0" + } + + val orderValue = (amountValue * BigDecimal(leverage.toDouble())).divide(priceValue, 8, RoundingMode.HALF_UP) + val result = orderValue.stripTrailingZeros().toPlainString() + + return result +} + +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 "${fiatSymbol}0" + } + + val liquidationPercent = BigDecimal(100.0 / leverage) + val liquidationRatio = liquidationPercent.divide(BigDecimal(100), 8, RoundingMode.HALF_UP) + val liquidationPrice = if (isLong) { + price * (BigDecimal.ONE - liquidationRatio) + } else { + price * (BigDecimal.ONE + liquidationRatio) + } + val fiatLiquidationPrice = liquidationPrice.multiply(fiatRate) + return "${fiatSymbol}${fiatLiquidationPrice.priceFormat()}" +} + +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/perps/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt new file mode 100644 index 0000000000..3fbbb64ff9 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt @@ -0,0 +1,541 @@ +package one.mixin.android.ui.home.web3.trade.perps + +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 +import androidx.compose.foundation.Image +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.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.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.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 +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.Constants +import one.mixin.android.R +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.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences +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 +import one.mixin.android.vo.Fiats +import java.math.BigDecimal +import kotlin.math.abs + +private const val POSITION_REFRESH_INTERVAL_MS = 3_000L +private const val CLOSED_POSITION_PREVIEW_LIMIT = 10 + +@Composable +fun PerpetualContent( + onShowTradingGuide: () -> Unit, + onShowMarketList: (isLong: Boolean) -> Unit, + onShowAllMarkets: () -> Unit, + onShowAllOpenPositions: () -> Unit, + onShowAllClosedPositions: () -> Unit, + onOpenPositionClick: (PerpsPositionItem) -> Unit, + 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 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) } + 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()) + var previousOpenPositionsCount by remember(walletId) { mutableStateOf(null) } + val openPositionsCount = openPositions.size + val openPositionsPreview = openPositions.take(3) + val marketsPreview = markets.take(3) + val closedPositionsPreview = closedPositions.take(3) + 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( + onSuccess = { data -> + markets = data + isLoading = false + }, + onError = { error -> + errorMessage = error + isLoading = false + } + ) + } + + LaunchedEffect(walletId, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.loadAcceptedAssets( + onSuccess = { assetIds -> + context.defaultSharedPreferences.putStringSet( + Constants.Account.PREF_PERPS_ACCEPTED_ASSET_IDS, + assetIds.toSet() + ) + } + ) + viewModel.refreshPositionHistory(walletId, limit = CLOSED_POSITION_PREVIEW_LIMIT) + while (isActive) { + viewModel.refreshPositions(walletId) + 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 + .weight(1f) + .fillMaxWidth() + .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 = totalPositionValueFiatText, + fontSize = 18.sp, + fontWeight = FontWeight.W600, + color = MixinAppTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = totalPnlFiatText, + fontSize = 14.sp, + color = if (totalPnl >= 0) risingColor else fallingColor, + ) + Text( + text = String.format("(%s%.2f%%)", if (totalPnlPercent >= 0) "+" else "", abs(totalPnlPercent)), + fontSize = 14.sp, + color = if (totalPnl >= 0) risingColor else fallingColor, + ) + } + } + + 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() + .clickable(onClick = onShowAllOpenPositions), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.positions_count, 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)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + 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_Position), + 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, + ) + } + } else { + openPositionsPreview.forEach { position -> + OpenPositionItem( + position = position, + onClick = { + val targetMarket = markets.firstOrNull { it.marketId == position.marketId } + if (targetMarket != null) { + onMarketItemClick(targetMarket) + } else { + onOpenPositionClick(position) + } + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + if (openPositionsCount > openPositionsPreview.size) { + ViewAllAction(onClick = onShowAllOpenPositions) + } + } + } + + 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( + modifier = Modifier + .fillMaxWidth() + .clickable { onShowAllMarkets() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.perps_markets), + 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) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + + 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 { + marketsPreview.forEach { market -> + PerpsMarketItem( + market = market, + quoteColorReversed = quoteColorReversed, + onClick = { + onMarketItemClick(market) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + if (markets.size > marketsPreview.size) { + ViewAllAction( + onClick = onShowAllMarkets + ) + } + } + } + + 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( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onShowAllClosedPositions), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.perps_activity), + 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) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + + if (closedPositions.isEmpty()) { + 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.backgroundGrayLight, + modifier = Modifier.size(78.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.No_Activity), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } else { + closedPositionsPreview.forEach { position -> + ClosedPositionItem( + position = position, + onClick = { onClosedPositionClick(position) }) + Spacer(modifier = Modifier.height(12.dp)) + } + + if (closedPositions.size > closedPositionsPreview.size) { + ViewAllAction(onClick = onShowAllClosedPositions) + } + } + } + + 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( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + onShowMarketList(true) + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = risingColor, + contentColor = Color.White + ), + enabled = markets.isNotEmpty() + ) { + Text( + text = stringResource(R.string.Long), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + + Button( + onClick = { + onShowMarketList(false) + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = fallingColor, + contentColor = Color.White + ), + enabled = markets.isNotEmpty() + ) { + Text( + text = stringResource(R.string.Short), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} + +@Composable +private fun ViewAllAction(onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.view_all), + fontSize = 14.sp, + color = MixinAppTheme.colors.accent, + fontWeight = FontWeight.W500, + ) + } +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..bd5e1fd2dd --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuideBottomSheetDialogFragment.kt @@ -0,0 +1,84 @@ +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 +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 PerpetualGuideBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + + companion object { + const val TAG = "PerpetualGuideBottomSheetDialogFragment" + private const val ARGS_INITIAL_TAB = "args_initial_tab" + + 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) = PerpetualGuideBottomSheetDialogFragment().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 { + PerpetualGuidePage( + 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/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt new file mode 100644 index 0000000000..a9afccce35 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -0,0 +1,1075 @@ +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 +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.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.Alignment +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 +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 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 +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.roundToInt + +data class ScenarioData( + val scenario: String, + val change: String, + val initialPercent: Float = 10f, + val basePnlAmount: Int, + val basePnlPercent: Int, + val pnlAsset: String = "USDT", + val isProfit: Boolean, + val maxPercent: Float? = null, +) + +data class GuideRowData( + val label: String, + val value: String, + @DrawableRes val iconRes: Int? = null, +) + +data class AdjusterConfig( + val min: Int, + val max: Int, + val step: Int, +) + +@Composable +fun PerpetualGuidePage( + initialTab: Int = PerpetualGuideBottomSheetDialogFragment.TAB_OVERVIEW, + pop: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val tabs = listOf( + stringResource(R.string.Brief_Introduction), + stringResource(R.string.Long), + stringResource(R.string.Short), + stringResource(R.string.Leverage), + stringResource(R.string.position_size) + ) + val safeInitialTab = initialTab.coerceIn(0, tabs.lastIndex) + var selectedTab by remember(safeInitialTab) { mutableIntStateOf(safeInitialTab) } + + MixinAppTheme { + Column( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(topEnd = 8.dp, topStart = 8.dp)) + .background(MixinAppTheme.colors.background) + ) { + 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.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 { + 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 + .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 } + }, + onClose = pop, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + } +} + + +@Composable +private fun OverviewContent() { + GuideSection( + title = stringResource(R.string.Overview), + content = stringResource(R.string.perps_intro_overview) + ) +} + +@Composable +private fun LongContent() { + val leverage = 10 + val maxLossPercent = 100f / leverage + ExampleWithScenariosCard( + title = stringResource(R.string.Example), + rows = listOf( + GuideRowData( + label = stringResource(R.string.example_perpetual), + value = "BTC - USD", + iconRes = R.drawable.ic_chain_btc + ), + GuideRowData( + label = stringResource(R.string.Direction), + value = stringResource(R.string.Long) + ), + GuideRowData( + label = stringResource(R.string.Leverage), + value = "${leverage}x" + ), + GuideRowData( + label = stringResource(R.string.example_amount), + value = "1,000 USDT" + ) + ), + scenarios = listOf( + ScenarioData( + scenario = stringResource(R.string.Perpetual_Price_Up), + change = stringResource(R.string.example_price_increased), + initialPercent = maxLossPercent, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = true, + maxPercent = null + ), + ScenarioData( + scenario = stringResource(R.string.Perpetual_Price_Down), + change = stringResource(R.string.example_price_decreased), + initialPercent = maxLossPercent, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = false, + maxPercent = maxLossPercent, + ) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + DescriptionWithRulesCard( + 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) + ) + ) +} + +@Composable +private fun ShortContent() { + val leverage = 10 + val maxLossPercent = 100f / leverage + ExampleWithScenariosCard( + title = stringResource(R.string.Example), + rows = listOf( + GuideRowData( + label = stringResource(R.string.example_perpetual), + value = "ETH - USD", + iconRes = R.drawable.ic_chain_eth + ), + GuideRowData( + label = stringResource(R.string.Direction), + value = stringResource(R.string.Short) + ), + GuideRowData( + label = stringResource(R.string.Leverage), + value = "${leverage}x" + ), + GuideRowData( + label = stringResource(R.string.example_amount), + value = "1,000 USDT" + ) + ), + scenarios = listOf( + ScenarioData( + scenario = stringResource(R.string.Perpetual_Price_Down), + change = stringResource(R.string.example_price_decreased), + initialPercent = maxLossPercent, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = true, + maxPercent = null, + ), + ScenarioData( + scenario = stringResource(R.string.Perpetual_Price_Up), + change = stringResource(R.string.example_price_increased), + initialPercent = maxLossPercent, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = false, + maxPercent = maxLossPercent, + ) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + DescriptionWithRulesCard( + 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) + ) + ) +} + +@Composable +private fun LeverageContent() { + var leverage by remember { mutableIntStateOf(10) } + 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.Example), + rows = listOf( + GuideRowData( + label = stringResource(R.string.example_perpetual), + value = "SOL - USD", + iconRes = R.drawable.ic_chain_sol + ), + GuideRowData( + label = stringResource(R.string.Direction), + value = stringResource(R.string.Long) + ), + GuideRowData( + label = stringResource(R.string.Leverage), + value = "${leverage}x" + ), + GuideRowData( + label = stringResource(R.string.example_amount), + value = "1,000 USDT" + ) + ), + scenarios = listOf( + ScenarioData( + scenario = stringResource(R.string.Perpetual_Price_Up), + change = stringResource(R.string.example_price_increased), + initialPercent = fixedProfitPercent, + basePnlAmount = profitPnlAmount, + basePnlPercent = profitPnlPercent, + isProfit = true, + maxPercent = null + ), + ScenarioData( + scenario = stringResource(R.string.Perpetual_Price_Down), + change = stringResource(R.string.example_price_decreased), + initialPercent = liquidationPercent, + basePnlAmount = 1000, + basePnlPercent = 100, + isProfit = false, + maxPercent = liquidationPercent, + ) + ), + leverageValue = leverage, + onLeverageChange = { leverage = it.coerceIn(1, 200) }, + isScenarioChangeAdjustable = false, + ) + Spacer(modifier = Modifier.height(16.dp)) + DescriptionWithInfoAndRiskCard( + description = stringResource(R.string.perps_leverage_overview), + infoTitle = stringResource(R.string.impact_on_pnl), + infoContents = listOf( + stringResource(R.string.impact_on_pnl_1), + stringResource(R.string.impact_on_pnl_2) + ), + riskContents = listOf(stringResource(R.string.perps_leverage_risk_notice)) + ) +} + +@Composable +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 fixedProfitPercent = 10f + 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 profitPnlAmount = (orderValueUsdt * fixedProfitPercent / 100).roundToInt() + val profitPnlPercent = (leverage * fixedProfitPercent).roundToInt() + val lossPnlAmount = investment + val lossPnlPercent = 100 + + ExampleWithScenariosCard( + title = stringResource(R.string.Example), + rows = listOf( + GuideRowData( + label = stringResource(R.string.example_perpetual), + value = "SOL - USD", + iconRes = R.drawable.ic_chain_sol + ), + GuideRowData( + label = stringResource(R.string.Direction), + value = stringResource(R.string.Long) + ), + GuideRowData( + label = stringResource(R.string.Leverage), + value = "${leverage}x" + ), + GuideRowData( + label = stringResource(R.string.example_amount), + value = "${formatGuideInt(investment)} USDT" + ), + GuideRowData( + label = stringResource(R.string.position_size), + value = orderValueText + ) + ), + scenarios = listOf( + ScenarioData( + scenario = stringResource(R.string.Perpetual_Price_Up), + change = stringResource(R.string.example_price_increased), + initialPercent = fixedProfitPercent, + basePnlAmount = profitPnlAmount, + basePnlPercent = profitPnlPercent, + isProfit = true, + maxPercent = null + ), + ScenarioData( + scenario = stringResource(R.string.Perpetual_Price_Down), + change = stringResource(R.string.example_price_decreased), + initialPercent = maxLossPercent, + basePnlAmount = lossPnlAmount, + basePnlPercent = lossPnlPercent, + isProfit = false, + maxPercent = maxLossPercent, + ) + ), + leverageValue = leverage, + onLeverageChange = { leverage = it.coerceIn(1, 200) }, + leverageConfig = AdjusterConfig(min = 1, max = 200, step = 1), + investmentValue = investment, + onInvestmentChange = { investment = it.coerceIn(100, 100000) }, + investmentConfig = AdjusterConfig(min = 100, max = 100000, step = 100), + isScenarioChangeAdjustable = false, + ) + Spacer(modifier = Modifier.height(16.dp)) + DescriptionWithInfoAndRiskCard( + description = stringResource(R.string.perps_position_size_overview), + infoTitle = stringResource(R.string.Purpose), + infoContents = listOf( + stringResource(R.string.perps_position_size_purpose_1), + stringResource(R.string.perps_position_size_purpose_2) + ), + riskContents = listOf(stringResource(R.string.perps_position_size_risk_1) , stringResource(R.string.perps_position_size_risk_2)) + ) +} + + +@Composable +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 + .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( + 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 = content, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + 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.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( + text = feature, + modifier = Modifier.padding(vertical = 4.dp), + color = MixinAppTheme.colors.textPrimary, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + 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.perps_intro_risk_notice_1), + color = MixinAppTheme.colors.textPrimary + ) + DotText( + text = stringResource(R.string.perps_intro_risk_notice_2), + color = MixinAppTheme.colors.textPrimary + ) + DotText( + text = stringResource(R.string.perps_intro_risk_notice_3), + color = MixinAppTheme.colors.textPrimary + ) + } +} + +@Composable +private fun ExampleWithScenariosCard( + title: String, + rows: List, + 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, +) { + 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 orderedScenarios = scenarios.sortedByDescending { it.isProfit } + val changePercents = remember(scenarios.hashCode(), leverageValue, investmentValue) { + mutableStateListOf().apply { + addAll( + orderedScenarios.map { scenario -> + val nonNegativePercent = scenario.initialPercent.coerceAtLeast(0f) + scenario.maxPercent?.let { maxPercent -> + nonNegativePercent.coerceAtMost(maxPercent) + } ?: nonNegativePercent + } + ) + } + } + val directionLabel = stringResource(R.string.Direction) + val leverageLabel = stringResource(R.string.Leverage) + val investmentLabel = stringResource(R.string.example_amount) + val longDirection = stringResource(R.string.Long) + val shortDirection = stringResource(R.string.Short) + 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)) + rows.forEachIndexed { index, row -> + val label = row.label + val value = row.value + if (index > 0) { + Spacer(modifier = Modifier.height(16.dp)) + } + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + 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, + modifier = Modifier + .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 > 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 > 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) { + 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 + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + orderedScenarios.forEachIndexed { index, scenario -> + if (index > 0) { + Spacer(modifier = Modifier.height(16.dp)) + } + val scenarioTitle = stringResource( + R.string.Perpetual_Scenario_Title_Format, + index + 1, + scenario.scenario, + ) + Column( + modifier = Modifier + .fillMaxWidth() + ) { + 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( + text = scenario.change, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.weight(1f) + ) + val percent = changePercents[index] + Row( + verticalAlignment = Alignment.CenterVertically, + ) + { + if (isScenarioChangeAdjustable) { + val maxPercent = scenario.maxPercent + val step = if (percent < 1f) 0.1f else 1f + val canDecrease = percent > 0f + val canIncrease = if (maxPercent == null) { + true + } else { + percent < maxPercent + } + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MixinAppTheme.colors.backgroundWindow) + .alpha(if (canDecrease) 1f else 0.5f) + .clickable(enabled = canDecrease) { + changePercents[index] = (percent - step).coerceAtLeast(0f) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_minus), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(16.dp), + ) + } + Text( + text = formatPercent(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) { + percent + step + } else { + (percent + step).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 = formatPercent(percent), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary, + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.PnL), + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + modifier = Modifier.weight(1f) + ) + Text( + text = scenario.formatPnl(changePercents[index]), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (scenario.isProfit) risingColor else fallingColor + ) + } + } + } + } +} + +@Composable +private fun GuideNumberAdjuster( + valueText: String, + canDecrease: Boolean, + canIncrease: Boolean, + onDecrease: () -> Unit, + onIncrease: () -> Unit, +) { + 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 = valueText, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + 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 solPrice = localSolPrice ?: return "-- SOL" + if (solPrice <= BigDecimal.ZERO) { + return "-- SOL" + } + val solAmount = BigDecimal(orderValueUsdt.toString()) + .divide(solPrice, 2, RoundingMode.HALF_UP) + .stripTrailingZeros() + .toPlainString() + return "$solAmount SOL" +} + +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 "-" + val amountText = String.format("%,d", amount) + return if (isProfit) { + "$sign$amountText $pnlAsset ($sign$percent%)" + } else { + "$sign$amountText $pnlAsset" + } +} + +@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.Overview), + fontSize = 16.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.PnL_Rules), + fontSize = 16.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(12.dp)) + rules.forEach { (condition, result) -> + DotText( + text = "$condition:$result", + modifier = Modifier.padding(vertical = 4.dp), + color = MixinAppTheme.colors.textPrimary, + ) + } + } +} + +@Composable +private fun DescriptionWithInfoAndRiskCard( + description: String, + infoTitle: String, + infoContents: List, + riskContents: List, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.Overview), + fontSize = 16.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = infoTitle, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textMinor + ) + Spacer(modifier = Modifier.height(6.dp)) + infoContents.forEach { content -> + DotText( + text = content, + modifier = Modifier.padding(vertical = 2.dp), + color = MixinAppTheme.colors.textPrimary, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.Risk_Notice), + fontSize = 14.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(6.dp)) + 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/PerpetualViewModel.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt new file mode 100644 index 0000000000..4df3abeb39 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt @@ -0,0 +1,705 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 +import one.mixin.android.api.request.perps.CloseOrderRequest +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.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 +import one.mixin.android.job.RefreshTokensJob +import one.mixin.android.util.ErrorHandler +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: TokenDao, + private val perpsPositionDao: PerpsPositionDao, + private val perpsPositionHistoryDao: PerpsPositionHistoryDao, + private val perpsMarketDao: PerpsMarketDao, + private val jobManager: MixinJobManager +) : ViewModel() { + + fun refreshPositions(walletId: String) { + jobManager.addJobInBackground(RefreshPerpsPositionsJob(walletId)) + } + + fun refreshPositionHistory(walletId: String, limit: Int = 100) { + viewModelScope.launch { + try { + val latestClosedAt = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getLatestClosedAt() + } + val response = withContext(Dispatchers.IO) { + routeService.getPerpsPositionHistory( + walletId = walletId, + limit = limit, + offset = latestClosedAt + ) + } + + val data = response.data + if (response.isSuccess && data != null) { + withContext(Dispatchers.IO) { + perpsPositionHistoryDao.insertAll(data) + } + Timber.d("Perps position history refreshed: ${data.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 + ) { + 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) + } + + val data = response.data + if (response.isSuccess && data != null) { + Timber.d("Perps markets loaded: ${data.size} markets") + + val orderedMarkets = withContext(Dispatchers.IO) { + perpsMarketDao.replaceAll(data) + perpsMarketDao.getAllMarkets() + } + + onSuccess(orderedMarkets) + } else { + val error = "Failed to load markets: ${response.errorDescription}" + Timber.e(error) + if (cachedMarkets.isEmpty()) { + onError(error) + } + } + } catch (e: Exception) { + val error = "Error loading markets: ${e.message}" + Timber.e(e, error) + + val cachedMarkets = withContext(Dispatchers.IO) { + perpsMarketDao.getAllMarkets() + } + if (cachedMarkets.isEmpty()) { + onError(error) + } + } + } + } + + fun loadMarketDetail( + marketId: String, + onSuccess: (PerpsMarket) -> 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.displaySymbol}") + 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) + } + } + } + + 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) { + 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) + 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 { + val usdTokens = withContext(Dispatchers.IO) { + val usdIds = Constants.usdIds + tokenDao.findTokenItems(usdIds) + .sortedByDescending { + it.balance.toBigDecimalOrNull() ?: BigDecimal.ZERO + } + } + onSuccess(usdTokens) + } catch (e: Exception) { + Timber.e(e, "Error loading USD tokens") + onSuccess(emptyList()) + } + } + } + + fun openPerpsOrder( + assetId: String, + marketId: String, + side: String, + amount: String, + leverage: Int, + walletId: String, + destination: String? = null, + entryPrice: String, + onSuccess: (OpenOrderResponse) -> Unit, + onError: (Int, String) -> Unit + ) { + viewModelScope.launch { + try { + val request = OpenOrderRequest( + assetId = assetId, + marketId = marketId, + 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}, payUrl: ${data.paymentUrl}") + + val position = PerpsPosition( + positionId = data.orderId, + marketId = marketId, + side = side, + quantity = amount, + settleAssetId = assetId, + botId = "", + entryPrice = entryPrice, + margin = amount, + openPayAmount = data.payAmount, + openPayAssetId = assetId, + leverage = leverage, + state = "pending", + markPrice = entryPrice, + unrealizedPnl = "0", + roe = "0", + walletId = walletId, + 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) { + perpsPositionDao.insert(position) + } + + onSuccess(data) + } else { + 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(-1, 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 observeOpenPositions(walletId: String): Flow> { + return perpsPositionDao.observeOpenPositions(walletId) + } + + fun observePosition(positionId: String): Flow { + return perpsPositionDao.observePosition(positionId) + } + + fun observeTokenByChainAndSymbol(chainId: String, symbol: String): Flow { + return tokenDao.assetItemFlowByChainAndSymbol(chainId, symbol) + } + + fun observeTokenByAssetId(assetId: String): Flow { + return tokenDao.assetItemFlow(assetId) + } + + 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) { + 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) { + Timber.d("Skip deleting local position on NOT_FOUND during refresh: $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(limit) + } + + 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(limit) + } catch (e: Exception) { + Timber.e(e, "Error loading closed positions from db") + 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) + } + } + } + + 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 { + perpsPositionDao.getTotalUnrealizedPnl(walletId) ?: 0.0 + } catch (e: Exception) { + Timber.e(e, "Error loading total unrealized PnL from db") + 0.0 + } + } + } + + 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 { + perpsPositionHistoryDao.getTotalRealizedPnl() ?: 0.0 + } catch (e: Exception) { + Timber.e(e, "Error loading total realized PnL from db") + 0.0 + } + } + } + + fun observeTotalRealizedPnl(walletId: String): Flow { + return perpsPositionHistoryDao.observeTotalRealizedPnl() + } + + suspend fun getTotalClosedEntryValueFromDb(walletId: String): Double { + return withContext(Dispatchers.IO) { + try { + perpsPositionHistoryDao.getTotalClosedEntryValue() ?: 0.0 + } catch (e: Exception) { + Timber.e(e, "Error loading total closed entry value from db") + 0.0 + } + } + } + + fun observeTotalClosedEntryValue(walletId: String): Flow { + return perpsPositionHistoryDao.observeTotalClosedEntryValue() + } + + 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(), config) + .setInitialLoadKey(initialLoadKey) + .build() + } + + 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()) + } + } + } + + fun getPositionByMarket(walletId: String, marketId: String, onSuccess: (PerpsPositionItem?) -> 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 getClosedPositionsByMarket(walletId: String, marketId: String, onSuccess: (List) -> Unit) { + viewModelScope.launch { + try { + val allHistories = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getHistories(100) + } + val filteredHistories = allHistories.filter { it.marketId == marketId } + onSuccess(filteredHistories) + } catch (e: Exception) { + Timber.e(e, "Error loading closed positions by market") + onSuccess(emptyList()) + } + } + } + + fun closePerpsOrder( + positionId: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val request = CloseOrderRequest( + positionId = positionId + ) + + val response = withContext(Dispatchers.IO) { + routeService.closePerpsOrder(request) + } + + if (response.isSuccess) { + withContext(Dispatchers.IO) { + perpsPositionDao.deleteById(positionId) + } + Timber.d("Perps order closed: $positionId") + 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 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}") + } + } + } + + 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, + onError: (String) -> Unit + ) { + 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) { + val resolvedWalletId = data.walletId.ifBlank { localBefore?.walletId ?: "" } + val positionForDb = data.copy(walletId = resolvedWalletId) + + withContext(Dispatchers.IO) { + perpsPositionDao.insert(positionForDb) + } + + onSuccess(positionForDb) + } 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) + } + } + } + + fun loadPositionHistory( + walletId: String, + limit: Int = 10, + offset: String? = null, + onSuccess: (List) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + val cachedHistories = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getHistories(limit, offset) + } + + if (cachedHistories.isNotEmpty()) { + onSuccess(cachedHistories) + } + + 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") + withContext(Dispatchers.IO) { + perpsPositionHistoryDao.insertAll(data) + } + + val updatedHistories = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getHistories(limit, offset) + } + onSuccess(updatedHistories) + } else { + val error = "Failed to load position history: ${response.errorDescription}" + Timber.e(error) + if (cachedHistories.isEmpty()) { + onError(error) + } + } + } catch (e: Exception) { + val error = "Error loading position history: ${e.message}" + Timber.e(e, error) + + val cachedHistories = withContext(Dispatchers.IO) { + perpsPositionHistoryDao.getHistories(limit, offset) + } + if (cachedHistories.isEmpty()) { + onError(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 new file mode 100644 index 0000000000..63b8089a80 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsActivity.kt @@ -0,0 +1,205 @@ +package one.mixin.android.ui.home.web3.trade.perps + +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 androidx.lifecycle.Lifecycle +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 +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 one.mixin.android.session.Session +import one.mixin.android.ui.common.BaseActivity +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 + +@AndroidEntryPoint +class PerpsActivity : BaseActivity() { + + @Inject + lateinit var jobManager: MixinJobManager + @Inject + 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" + 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 = 3_000L + + const val MODE_DETAIL = "detail" + const val MODE_OPEN_POSITION = "open_position" + + fun showDetail( + context: Context, + marketId: String, + marketSymbol: String, + marketDisplaySymbol: String, + 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) + putExtra(EXTRA_MARKET_TOKEN_SYMBOL, marketTokenSymbol) + putExtra(EXTRA_MODE, MODE_DETAIL) + } + context.startActivity(intent) + } + + fun showOpenPosition( + context: Context, + marketId: String, + marketSymbol: String, + marketDisplaySymbol: String, + marketTokenSymbol: String = "", + 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) + putExtra(EXTRA_MARKET_TOKEN_SYMBOL, marketTokenSymbol) + putExtra(EXTRA_MODE, MODE_OPEN_POSITION) + putExtra(EXTRA_IS_LONG, isLong) + } + context.startActivity(intent) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + observePositionRefresh() + renderPage() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + renderPage() + } + + 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) { + renderJob = 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( + market = market, + isLong = isLong, + onBack = { finish() }, + onOpenSuccess = { openedMarketId -> + showDetail(this@PerpsActivity, openedMarketId, "", "", "") + }, + selectedToken = selectedToken, + onTokenSelect = { showTokenSelection() }, + onCurrentTokenChange = { token -> selectedToken = token } + ) + } + } + } + return + } + + 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() } + ) + } + } + } + } + + private fun showTokenSelection() { + TokenListBottomSheetDialogFragment.newInstance( + fromType = TokenListBottomSheetDialogFragment.TYPE_FROM_PERP, + 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 { + 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/PerpsCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..2106f31c5a --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsCloseBottomSheetDialogFragment.kt @@ -0,0 +1,599 @@ +package one.mixin.android.ui.home.web3.trade.perps + +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.DialogInterface +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.Icon +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 +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.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.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.priceFormat +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 +import one.mixin.android.ui.wallet.components.WalletLabel +import one.mixin.android.util.SystemUIManager +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 + +@AndroidEntryPoint +class PerpsCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFragment() { + + companion object { + const val TAG = "PerpsCloseBottomSheetDialogFragment" + private const val ARGS_POSITION_ID = "args_position_id" + private const val ARGS_SIDE = "args_side" + 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" + 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, + ): PerpsCloseBottomSheetDialogFragment { + return PerpsCloseBottomSheetDialogFragment().withArgs { + putString(ARGS_POSITION_ID, position.positionId) + putString(ARGS_SIDE, position.side) + 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) + } + } + } + + 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() + private val bottomViewModel by viewModels() + + enum class Step { + Pending, + Sending, + Done, + Error, + } + + private val positionId by lazy { + requireNotNull(requireArguments().getString(ARGS_POSITION_ID)) { "positionId is null" } + } + + private val margin by lazy { + requireNotNull(requireArguments().getString(ARGS_MARGIN)) { "margin 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 var step by mutableStateOf(Step.Pending) + private var errorInfo: String? by mutableStateOf(null) + + 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() { + 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 + latestUnrealizedPnl = unrealizedPnl + latestRoe = roe + } + + 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.marketId)?.let { market -> + marketIconUrl = market.iconUrl + marketSymbol = market.displaySymbol + } + } + + viewModel.loadPositionDetail( + positionId = positionId, + onSuccess = { position -> + latestMarkPrice = position.markPrice ?: "0" + latestUnrealizedPnl = position.unrealizedPnl ?: "0" + latestRoe = position.roe ?: "0" + + lifecycleScope.launch { + viewModel.getMarketFromDb(position.marketId)?.let { market -> + marketIconUrl = market.iconUrl + marketSymbol = market.displaySymbol + } + } + + viewModel.loadMarketDetail( + marketId = position.marketId, + onSuccess = { market -> + marketIconUrl = market.iconUrl + marketSymbol = market.displaySymbol + }, + onError = {} + ) + refreshAssetAndSender( + settleAssetId = position.settleAssetId, + botId = position.botId + ) + }, + onError = { error -> + Timber.e("Failed to load position detail: $error") + } + ) + } + + MixinAppTheme { + Column( + modifier = Modifier + .clip(shape = RoundedCornerShape(topStart = 8.composeDp, topEnd = 8.composeDp)) + .fillMaxWidth() + .fillMaxHeight() + .background(MixinAppTheme.colors.background), + ) { + WalletLabel(walletName = getString(R.string.Privacy_Wallet), isWeb3 = false) + 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 -> { + Icon( + modifier = Modifier.size(70.dp), + painter = painterResource(id = R.drawable.ic_transfer_status_failed), + contentDescription = null, + tint = Color.Unspecified, + ) + } + + Step.Done -> { + Icon( + modifier = Modifier.size(70.dp), + painter = painterResource(id = R.drawable.ic_transfer_status_success), + contentDescription = null, + tint = Color.Unspecified, + ) + } + + else -> { + 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 + ) + } + } + } + } + Box(modifier = Modifier.height(20.dp)) + Text( + text = stringResource( + id = when (step) { + Step.Pending -> R.string.confirm_closing_position + Step.Done -> R.string.Position_Closed + 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 ?: 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, + 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 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) { + risingColor + } else { + fallingColor + } + + val estimatedReceive = try { + val margin = BigDecimal(margin) + val unrealizedPnl = BigDecimal(latestUnrealizedPnl) + margin + unrealizedPnl + } catch (e: Exception) { + BigDecimal.ZERO + } + + val formattedRoe = try { + String.format("%f", latestRoe.toDouble()) + } 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" + } + + 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) { + 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 { + Text( + text = "${stringResource(R.string.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)) + } + + 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 = { showVerifyPinThenClose() }, + ) + } + + Step.Pending -> { + ActionBottom( + modifier = Modifier.align(Alignment.BottomCenter), + cancelTitle = stringResource(R.string.Cancel), + confirmTitle = stringResource(id = R.string.Confirm), + cancelAction = { dismiss() }, + confirmAction = { showVerifyPinThenClose() }, + ) + } + + Step.Sending -> {} + } + } + Box(modifier = Modifier.height(36.dp)) + } + } + } + + 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 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, + onSuccess = { + 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 + } + } + + botId?.let { userId -> + sender = bottomViewModel.refreshUser(userId) + } + } + } + + 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 + } +} 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 new file mode 100644 index 0000000000..e211e9ef30 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsConfirmBottomSheetDialogFragment.kt @@ -0,0 +1,695 @@ +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.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 +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.Icon +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.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 +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.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.priceFormat +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.BottomSheetViewModel +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.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.Fiats +import one.mixin.android.vo.User +import one.mixin.android.vo.toUser +import timber.log.Timber +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.UUID + +@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_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_TOKEN_SYMBOL = "args_token_symbol" + private const val ARGS_PAY_URL = "args_pay_url" + + fun newInstance( + marketSymbol: String, + marketIcon: String, + isLong: Boolean, + amount: String, + leverage: Int, + entryPrice: String, + tokenSymbol: String, + payUrl: String?, + ): PerpsConfirmBottomSheetDialogFragment { + return PerpsConfirmBottomSheetDialogFragment().withArgs { + 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_TOKEN_SYMBOL, tokenSymbol) + putString(ARGS_PAY_URL, payUrl) + } + } + } + + 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 bottomViewModel by viewModels() + + enum class Step { + Pending, + Sending, + Done, + Error, + } + + 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 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 { + if (leverage <= 0) { + "${fiatSymbol}0" + } else { + val price = entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + if (price == BigDecimal.ZERO) { + "${fiatSymbol}0" + } else { + 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()}" + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to calculate liquidation price") + "${fiatSymbol}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 + .clip(shape = RoundedCornerShape(topStart = 8.composeDp, topEnd = 8.composeDp)) + .fillMaxWidth() + .fillMaxHeight() + .background(MixinAppTheme.colors.background), + ) { + WalletLabel(walletName = getString(R.string.Privacy_Wallet), isWeb3 = false) + 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 -> { + Icon( + modifier = Modifier.size(70.dp), + painter = painterResource(id = R.drawable.ic_transfer_status_failed), + contentDescription = null, + tint = Color.Unspecified, + ) + } + + Step.Done -> { + Icon( + modifier = Modifier.size(70.dp), + painter = painterResource(id = R.drawable.ic_transfer_status_success), + contentDescription = null, + tint = Color.Unspecified, + ) + } + + else -> { + CoilImage( + model = marketIcon, + 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.confirm_opening_position + Step.Done -> R.string.Position_Opened + 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 ?: 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, + 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)) + + 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.Direction).uppercase(), + value = "${if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short)} ${leverage}x" + ) + Box(modifier = Modifier.height(6.dp)) + ProfitLossInfo( + amount = amount, + leverage = leverage, + isLong = isLong + ) + Box(modifier = Modifier.height(20.dp)) + + PerpsInfoItem( + title = stringResource(R.string.Amount).uppercase(), + value = "$amount $tokenSymbol" + ) + Box(modifier = Modifier.height(20.dp)) + + PerpsInfoItem( + title = stringResource(R.string.Entry_Price).uppercase(), + value = entryFiatPrice + ) + 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.Estimated_Liquidation_Price).uppercase(), + value = liquidationPrice, + subValue = lossSubValue + ) + + Box(modifier = Modifier.height(20.dp)) + + ItemUserContent(title = stringResource(id = R.string.Receiver).uppercase(), receiver, null) + Box(modifier = Modifier.height(20.dp)) + + ItemWalletContent(title = stringResource(id = R.string.Sender).uppercase(), fontSize = 16.sp) + 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, + info: Boolean = false, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) { + 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 { + PerpetualGuideBottomSheetDialogFragment.newInstance( + PerpetualGuideBottomSheetDialogFragment.TAB_POSITION + ).show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } + } + 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) * fiatRate + val formattedProfitAmount = "${fiatSymbol}${profitAmount.priceFormat()}" + + Timber.d("ProfitLossInfo - amount: $amount, amountValue: $amountValue, leverage: $leverage, isLong: $isLong, profitPercent: $profitPercent, profitAmount: $profitAmount") + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + text = if (isLong) { + stringResource( + R.string.Price_Up_Profit, + "1", + String.format("%.1f", profitPercent), + formattedProfitAmount + ) + } else { + stringResource( + R.string.Price_Down_Profit, + "1", + String.format("%.1f", profitPercent), + formattedProfitAmount + ) + }, + color = MixinAppTheme.colors.textAssist, + fontSize = 14.sp, + ) + } + + + 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 + + if (payUrl != null) { + handlePayment(payUrl!!, pin) + } else { + defaultSharedPreferences.putLong( + Constants.BIOMETRIC_PIN_CHECK, + System.currentTimeMillis(), + ) + context?.updatePinCheck() + step = Step.Done + } + } 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), + "", + "", + ) + + 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() + } + + override fun showError(error: String) { + errorInfo = error + step = Step.Error + } +} 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 new file mode 100644 index 0000000000..1d6e7afe86 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -0,0 +1,932 @@ +package one.mixin.android.ui.home.web3.trade.perps + +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 +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.IconButton +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.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 +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 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 +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.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.numberFormatCompact +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 +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 + +private const val CLOSED_POSITION_PREVIEW_LIMIT = 100 +private const val MARKET_REFRESH_INTERVAL_MS = 30_000L + +@Composable +fun PerpsMarketDetailPage( + marketId: String, + marketSymbol: String, + displaySymbol: String, + tokenSymbol: String, + onBack: () -> Unit, +) { + 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) } + 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()) + 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") + 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), + ) + 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 + + 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) { + 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), + verticalScrollable = false, + 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( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 80.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!!, + marketId = marketId, + displaySymbol = displaySymbol, + tokenSymbol = tokenSymbol, + selectedTimeFrame = selectedTimeFrame, + timeFrameValues = timeFrameValues, + timeFrameLabels = timeFrameLabels, + onTimeFrameChange = { index -> + 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, + viewModel = viewModel, + onShare = { + PerpsPositionShareActivity.show(context, currentPosition) + }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + if (market != null) { + MarketInfoCard( + market = market!!, + onLearnClick = { + val activity = context as? FragmentActivity ?: return@MarketInfoCard + PerpetualGuideBottomSheetDialogFragment.newInstance( + PerpetualGuideBottomSheetDialogFragment.TAB_OVERVIEW + ).show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + } + ) + } + + 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() + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + if (market != null) { + 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 + .fillMaxWidth() + .height(48.dp), + onClick = { + val activity = context as? FragmentActivity ?: return@Button + val position = currentPosition.toPosition() + + PerpsCloseBottomSheetDialogFragment.newInstance( + position = position, + ).show(activity.supportFragmentManager, PerpsCloseBottomSheetDialogFragment.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) + ) { + Button( + modifier = Modifier + .weight(1f) + .height(48.dp), + onClick = { + PerpsActivity.showOpenPosition( + context = context, + marketId = marketId, + marketSymbol = marketSymbol, + marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, + marketTokenSymbol = market?.tokenSymbol ?: "", + 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.showOpenPosition( + context = context, + marketId = marketId, + marketSymbol = marketSymbol, + marketDisplaySymbol = market?.displaySymbol ?: marketSymbol, + marketTokenSymbol = market?.tokenSymbol ?: "", + 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 + ) + ) { + Text( + text = stringResource(R.string.Short), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } + } + } + } + } + } +} + +@Composable +private fun MarketInfoCard( + market: PerpsMarket, + onLearnClick: () -> Unit, +) { + val fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .cardBackground(MixinAppTheme.colors.background, MixinAppTheme.colors.borderColor) + .clickable { onLearnClick() } + .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(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.Volume_24H), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatVolume(market.volume, fiatRate, fiatSymbol), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.Funding_Rate), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFundingRate(market.fundingRate), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MixinAppTheme.colors.textPrimary + ) + } +} + +@Composable +private fun formatVolume( + volume: String, + fiatRate: BigDecimal, + fiatSymbol: String, +): String { + return try { + val fiatVolume = BigDecimal(volume).multiply(fiatRate) + "${fiatSymbol}${fiatVolume.numberFormatCompact()}" + } catch (e: NumberFormatException) { + stringResource(R.string.N_A) + } +} + +private fun formatFundingRate(fundingRate: String): String { + return runCatching { + BigDecimal(fundingRate).multiply(BigDecimal(100)).stripTrailingZeros().toPlainString() + "%" + }.getOrElse { fundingRate } +} + +@Composable +private fun MarketDetailCard( + market: PerpsMarket, + marketId: String, + displaySymbol: String, + tokenSymbol: String, + selectedTimeFrame: Int, + timeFrameValues: List, + timeFrameLabels: List, + onTimeFrameChange: (Int) -> 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 change = try { + BigDecimal(market.change) + } catch (e: Exception) { + BigDecimal.ZERO + } + + 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) + price.marketPriceFormat() + } catch (e: Exception) { + market.markPrice + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayTokenSymbol, + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(7.dp)) + Text( + text = "${fiatSymbol}$formattedPrice", + fontSize = 22.sp, + fontWeight = FontWeight.W500, + color = MixinAppTheme.colors.textPrimary + ) + Spacer(modifier = Modifier.height(8.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(16.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .clipToBounds() + ) { + CandleChart( + marketId = marketId, + timeFrame = timeFrameValues[selectedTimeFrame], + marketPrice = market.markPrice + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + timeFrameLabels.forEachIndexed { index, timeFrameLabel -> + Box( + modifier = Modifier + .height(36.dp) + .clip(RoundedCornerShape(18.dp)) + .then( + if (selectedTimeFrame == index) { + Modifier.background(MixinAppTheme.colors.backgroundWindow) + } else { + Modifier + } + ) + .clickable { onTimeFrameChange(index) } + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = timeFrameLabel, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (selectedTimeFrame == index) { + MixinAppTheme.colors.textPrimary + } else { + MixinAppTheme.colors.textAssist + } + ) + } + } + } + } +} + +@Composable +private fun OpenPositionCard( + position: PerpsPositionItem, + viewModel: PerpetualViewModel, + onShare: () -> 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 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) + + 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.perps_position), + fontSize = 16.sp, + 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)) + + 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(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(directionColor) + .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 + ) + } + 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 { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.position_size), + 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 + PerpetualGuideBottomSheetDialogFragment.newInstance( + PerpetualGuideBottomSheetDialogFragment.TAB_POSITION + ).show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + }, + tint = MixinAppTheme.colors.textAssist + ) + } + Text( + text = "${quantity.stripTrailingZeros().toPlainString()} ${position.tokenSymbol}", + fontSize = 14.sp, + color = MixinAppTheme.colors.textPrimary + ) + } + + Column(horizontalAlignment = Alignment.End) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.Margin), + 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 +// PerpetualGuideBottomSheetDialogFragment.newInstance() +// .show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) +// }, +// tint = MixinAppTheme.colors.textAssist +// ) + } + Text( + text = "${fiatSymbol}${amountValue.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) { + 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 +// PerpetualGuideBottomSheetDialogFragment.newInstance() +// .show(activity.supportFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) +// }, +// tint = MixinAppTheme.colors.textAssist +// ) + } + 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.perps_activity), + 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/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 new file mode 100644 index 0000000000..2ff33ea667 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketItem.kt @@ -0,0 +1,143 @@ +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 +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.res.stringResource +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.extension.priceFormat +import one.mixin.android.vo.Fiats +import java.math.BigDecimal + +@Composable +fun PerpsMarketItem( + market: PerpsMarket, + quoteColorReversed: Boolean = false, + onClick: () -> Unit = {} +) { + val change = try { + BigDecimal(market.change) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isPositive = change >= BigDecimal.ZERO + 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 fiatRate = BigDecimal(Fiats.getRate()) + val fiatSymbol = Fiats.getSymbol() + + val formattedPrice = try { + BigDecimal(market.markPrice).multiply(fiatRate).priceFormat() + } catch (e: Exception) { + market.markPrice + } + + val formattedVolume = try { + BigDecimal(market.volume).multiply(fiatRate).numberFormatCompact() + } catch (e: Exception) { + market.volume + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.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(42.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = market.tokenSymbol, + 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, + lineHeight = 14.sp, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MixinAppTheme.colors.backgroundGrayLight) + .padding(horizontal = 3.dp, vertical = 2.dp) + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.Vol, "$fiatSymbol$formattedVolume"), + fontSize = 12.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } + + Column( + horizontalAlignment = Alignment.End + ) { + Text( + text = "$fiatSymbol$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/perps/PerpsMarketListAdapter.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt new file mode 100644 index 0000000000..3fdb66904a --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListAdapter.kt @@ -0,0 +1,96 @@ +package one.mixin.android.ui.home.web3.trade.perps + +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 one.mixin.android.extension.priceFormat +import one.mixin.android.vo.Fiats +import java.math.BigDecimal + +class PerpsMarketListAdapter( + private val isQuoteColorReversed: Boolean, + 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 { + val fiatRate = BigDecimal(Fiats.getRate()) + 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() + } catch (e: Exception) { + market.volume + } + volumeTv.text = root.context.getString(R.string.Vol, "$fiatSymbol$formattedVolume") + + val formattedPrice = try { + BigDecimal(market.markPrice).multiply(fiatRate).priceFormat() + } catch (e: Exception) { + market.markPrice + } + priceTv.text = "$fiatSymbol$formattedPrice" + + val change = try { + BigDecimal(market.change) + } catch (e: Exception) { + BigDecimal.ZERO + } + + val isPositive = change >= BigDecimal.ZERO + 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 + changeTv.setTextColor(changeColor) + + root.setOnClickListener { + onMarketClick(market) + } + } + } + } +} 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 new file mode 100644 index 0000000000..3945fadf88 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketListBottomSheetDialogFragment.kt @@ -0,0 +1,153 @@ +package one.mixin.android.ui.home.web3.trade.perps + +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.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 +import one.mixin.android.widget.SearchView +import javax.inject.Inject + +@AndroidEntryPoint +class PerpsMarketListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { + + companion object { + const val TAG = "PerpsMarketListBottomSheetDialogFragment" + private const val ARGS_IS_LONG = "args_is_long" + + fun newInstance(isLong: Boolean) = PerpsMarketListBottomSheetDialogFragment().withArgs { + putBoolean(ARGS_IS_LONG, isLong) + } + } + + private val binding by viewBinding(FragmentMarketListBottomSheetBinding::inflate) + private val isQuoteColorReversed by lazy { + requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + } + private val adapter by lazy { + PerpsMarketListAdapter(isQuoteColorReversed) { market -> onMarketClick(market) } + } + + @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() + + @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 : 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.tokenSymbol.contains(query, ignoreCase = true) || + market.quoteSymbol.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) { + 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() + } + } +} 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 new file mode 100644 index 0000000000..24ab9789c7 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsPositionShareActivity.kt @@ -0,0 +1,345 @@ +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 +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.WindowCompat +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.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 +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.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 +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_QR_URL = "https://mixin.one/mm" + private val MIN_DISPLAY_PNL_PERCENT = BigDecimal("-100") + + 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 = SHARE_QR_URL + + 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) + 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)) + }) + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.TRANSPARENT + + binding.content.updateLayoutParams { + topMargin = 20.dp + } + binding.iconFl.round(6.dp) + binding.qr.post { binding.qr.round(4.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) + + val useProfitStyle = if (quoteColorReversed) !isProfit else isProfit + binding.topCard.setBackgroundResource( + 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 = formatFiat(entryPrice) + binding.latestLabelTv.text = latestLabel + binding.latestValueTv.text = formatFiat(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 displayValue = value.max(MIN_DISPLAY_PNL_PERCENT) + val sign = when { + displayValue > BigDecimal.ZERO -> "+" + displayValue < BigDecimal.ZERO -> "-" + else -> "" + } + val number = displayValue.abs().setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString() + return "$sign$number%" + } + + private fun formatFiat(value: String?): String { + val price = value.toBigDecimalSafely() ?: BigDecimal.ZERO + val fiatPrice = price.multiply(BigDecimal(Fiats.getRate())) + return "${Fiats.getSymbol()}${fiatPrice.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/perps/PositionDetailFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt new file mode 100644 index 0000000000..02011a6c87 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailFragment.kt @@ -0,0 +1,163 @@ +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.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 +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.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.ui.common.BaseFragment + +@AndroidEntryPoint +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" + private const val POSITION_REFRESH_INTERVAL_MS = 10_000L + + 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() + + private val quoteColorReversed: Boolean by lazy { + requireContext().defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + 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(), + ) { + 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 = currentPosition, + quoteColorReversed = quoteColorReversed, + pop = { + activity?.onBackPressedDispatcher?.onBackPressed() + }, + onClose = { + showCloseDialog(currentPosition) + }, + onShare = { + PerpsPositionShareActivity.show(requireContext(), currentPosition) + }, + onSupport = { + context?.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + } + ) + } else if (positionHistory != null) { + PositionDetailPage( + positionHistory = positionHistory, + quoteColorReversed = quoteColorReversed, + pop = { + activity?.onBackPressedDispatcher?.onBackPressed() + }, + onTradeAgain = { + openTradeAgain(positionHistory) + }, + onShare = { + PerpsPositionShareActivity.show(requireContext(), positionHistory) + }, + onSupport = { + context?.openUrl(Constants.HelpLink.CUSTOMER_SERVICE) + } + ) + } + } + } + } + } + + private fun showCloseDialog(position: PerpsPositionItem) { + val perpsPosition = position.toPosition() + PerpsCloseBottomSheetDialogFragment.newInstance(perpsPosition) + .setOnDone { + PerpsActivity.showDetail( + requireContext(), + position.marketId, + position.displaySymbol.orEmpty(), + position.displaySymbol.orEmpty(), + position.tokenSymbol.orEmpty() + ) + } + .showNow(parentFragmentManager, PerpsCloseBottomSheetDialogFragment.TAG) + } + + private fun openTradeAgain(positionHistory: PerpsPositionHistoryItem) { + PerpsActivity.showDetail( + context = requireContext(), + marketId = positionHistory.marketId, + marketSymbol = positionHistory.displaySymbol.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 new file mode 100644 index 0000000000..4f29e7ac41 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PositionDetailPage.kt @@ -0,0 +1,605 @@ +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.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.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.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 +import androidx.compose.ui.unit.sp +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.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 org.threeten.bp.Instant +import org.threeten.bp.ZoneId +import org.threeten.bp.format.DateTimeFormatter +import java.math.BigDecimal +import java.math.RoundingMode + +@Composable +fun PositionDetailPage( + position: PerpsPositionItem, + quoteColorReversed: Boolean = false, + pop: () -> Unit, + onClose: (() -> Unit)? = null, + onShare: (() -> Unit)? = null, + onSupport: (() -> Unit)? = null, +) { + 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 instant = Instant.parse(dateStr) + instant.atZone(ZoneId.systemDefault()).format(dateFormat) + } catch (e: Exception) { + dateStr + } + } + + val risingColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val fallingColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + 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 = if (isLong) { + stringResource(R.string.Opened_Long) + } else { + stringResource(R.string.Opened_Short) + } + + 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() + + fun formatFiat(value: BigDecimal): String { + return "$fiatSymbol${value.multiply(fiatRate).priceFormat()}" + } + + PageScaffold( + title = title, + verticalScrollable = false, + pop = pop, + actions = { + IconButton(onClick = { onSupport?.invoke() }) { + Icon( + painter = painterResource(id = R.drawable.ic_support), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) + } + } + ) { + 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)) + + Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { + 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)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(sideColor.copy(alpha = 0.1f)) + .padding(horizontal = 8.dp, vertical = 2.5.dp) + .align(Alignment.CenterHorizontally) + ) { + Text( + text = "$sideText ${position.leverage}x", + color = sideColor, + 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.Close_Position), + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + modifier = Modifier + .weight(1f) + .clickable { onClose?.invoke() } + .padding(vertical = 10.dp), + textAlign = 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) + .clickable { onShare?.invoke() } + .padding(vertical = 10.dp), + textAlign = 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.position_size).uppercase(), + value = "${String.format("%f", absQuantity)} ${position.tokenSymbol ?: ""}", + subtitle = formatFiat(orderValue) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Entry_Price).uppercase(), + 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)) + + 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)) + } + } +} + +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, + value: String, + icon: String? = null, + subtitle: 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 + ) + } + + if (subtitle != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = subtitle, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist + ) + } + } +} + + +@Composable +fun PositionDetailPage( + positionHistory: PerpsPositionHistoryItem, + quoteColorReversed: Boolean = false, + pop: () -> Unit, + onTradeAgain: (() -> Unit)? = null, + onShare: (() -> Unit)? = null, + onSupport: (() -> Unit)? = null, +) { + 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 instant = Instant.parse(dateStr) + instant.atZone(ZoneId.systemDefault()).format(dateFormat) + } catch (e: Exception) { + dateStr + } + } + + val pnl = try { + BigDecimal(positionHistory.realizedPnl) + } 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 isLong = positionHistory.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 = if (isLong) { + stringResource(R.string.Closed_Long) + } else { + stringResource(R.string.Closed_Short) + } + + val quantity = positionHistory.quantity.toBigDecimalOrNull() ?: BigDecimal.ZERO + val absQuantity = quantity.abs() + val closePrice = positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO + 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()}" + } + + fun formatSignedFiat(value: BigDecimal): String { + return when { + value > BigDecimal.ZERO -> "+${formatFiat(value)}" + value < BigDecimal.ZERO -> "-${formatFiat(value.abs())}" + 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, + verticalScrollable = false, + pop = pop, + actions = { + IconButton(onClick = { onSupport?.invoke() }) { + Icon( + painter = painterResource(id = R.drawable.ic_support), + contentDescription = null, + tint = MixinAppTheme.colors.icon, + ) + } + } + ) { + 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)) + + Row( + modifier = Modifier.align(Alignment.CenterHorizontally), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = formatPnlAmount(pnl), + fontSize = 34.sp, + fontWeight = FontWeight.W500, + fontFamily = FontFamily(Font(R.font.mixin_font)), + 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)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(sideColor.copy(alpha = 0.1f)) + .padding(horizontal = 8.dp, vertical = 2.5.dp) + .align(Alignment.CenterHorizontally) + ) { + Text( + text = "$sideText ${positionHistory.leverage}x", + color = sideColor, + 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) + .clickable { onTradeAgain?.invoke() } + .padding(vertical = 10.dp), + textAlign = 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) + .clickable { onShare?.invoke() } + .padding(vertical = 10.dp), + textAlign = 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.position_size).uppercase(), + value = "${String.format("%f", absQuantity)} ${positionHistory.tokenSymbol ?: ""}", + subtitle = formatFiat(orderValue) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Entry_Price).uppercase(), + value = formatFiat(positionHistory.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PositionDetailItem( + label = stringResource(R.string.Close_Price).uppercase(), + value = formatFiat(positionHistory.closePrice.toBigDecimalOrNull() ?: BigDecimal.ZERO) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + 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/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/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/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/MarketDetailsFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt index 0f5f1f3c6b..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 @@ -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/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/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/TokenListBottomSheetDialogFragment.kt index 32eb51d53c..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,7 +62,9 @@ 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 +88,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 } } @@ -97,9 +100,46 @@ 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 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) { + radio.isVisible = false + return + } radio.isVisible = true radioAll.isChecked = true radio.scrollToCenterCheckedRadio(radioGroup) @@ -226,23 +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 = 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) } } @@ -252,13 +285,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) @@ -291,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 } @@ -308,15 +345,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 +370,8 @@ class TokenListBottomSheetDialogFragment : MixinBottomSheetDialogFragment() { it } } + } + val result = sortQueryAsset(query, localAssets, remoteAssets) adapter.submitList(result) { @@ -335,7 +382,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/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/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() 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..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,44 +119,8 @@ 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.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, ) { @@ -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 + } } } } @@ -508,7 +476,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, ) } @@ -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/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/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) { + } +} 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/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/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/wallet/fiatmoney/OrderStatusFragment.kt b/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/OrderStatusFragment.kt index 155af0cacb..5e709dea08 100644 --- a/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/OrderStatusFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/wallet/fiatmoney/OrderStatusFragment.kt @@ -519,8 +519,6 @@ class OrderStatusFragment : BaseFragment(R.layout.fragment_order_status) { RiskConfig( BuildConfig.CHCEKOUT_ID, RISK_ENVIRONMENT, - false, - null, ), ) if (riskInstance == null) { 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) 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..cc02357c06 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,8 @@ open class ErrorHandler { const val NO_AVAILABLE_QUOTE = 10615 const val SIMULATE_TRANSACTION_FAILED = 10631 const val MAX_WALLET_REACHED = 10632 + const val PERPS_ORDER_VALUE_TOO_SMALL = 10650 + const val PERPS_MARKET_ALREADY_HAS_ACTIVE_POSITION = 10651 const val UNSUPPORTED_WATCH_ADDRESS = 10633 const val INVALID_REFERRAL_CODE = 10730 @@ -328,6 +330,12 @@ 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.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/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-xxhdpi/ic_perps_loss.png b/app/src/main/res/drawable-xxhdpi/ic_perps_loss.png new file mode 100644 index 0000000000..46f6900f7a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_perps_loss.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_perps_profit.png b/app/src/main/res/drawable-xxhdpi/ic_perps_profit.png new file mode 100644 index 0000000000..18f5c1dbd5 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_perps_profit.png differ 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 0000000000..7ea990e243 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_0.png differ 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 0000000000..0209924fb0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_1.png differ diff --git a/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_2.png b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_2.png new file mode 100644 index 0000000000..ccbd3eded2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_2.png differ 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 0000000000..b45f816528 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_3.png differ 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 0000000000..d7a92a3b55 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_4.png differ 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 0000000000..bd449738e3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_5.png differ diff --git a/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_6.png b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_6.png new file mode 100644 index 0000000000..2dcd0fa3fd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_6.png differ 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 0000000000..cabfe94405 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/mixin_import_safety_preview_7.png differ diff --git a/app/src/main/res/drawable/bg_card.xml b/app/src/main/res/drawable/bg_card.xml index 7725e6f319..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 diff --git a/app/src/main/res/drawable/bg_card_bottom.xml b/app/src/main/res/drawable/bg_card_bottom.xml new file mode 100644 index 0000000000..9e43cff5bd --- /dev/null +++ b/app/src/main/res/drawable/bg_card_bottom.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + 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/drawable/bg_perps_leverage_long.xml b/app/src/main/res/drawable/bg_perps_leverage_long.xml new file mode 100644 index 0000000000..d4a7c09559 --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_leverage_long.xml @@ -0,0 +1,6 @@ + + + + + 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..2f1c3adea3 --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_leverage_short.xml @@ -0,0 +1,6 @@ + + + + + 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..83a10c1ed2 --- /dev/null +++ b/app/src/main/res/drawable/bg_perps_share_tag.xml @@ -0,0 +1,6 @@ + + + + + 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/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/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_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 @@ + + + + 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/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_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 new file mode 100644 index 0000000000..99a739749c --- /dev/null +++ b/app/src/main/res/layout/activity_perps_position_share.xml @@ -0,0 +1,341 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/layout/fragment_all_closed_positions.xml b/app/src/main/res/layout/fragment_all_closed_positions.xml new file mode 100644 index 0000000000..2f10842776 --- /dev/null +++ b/app/src/main/res/layout/fragment_all_closed_positions.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + 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_closed_position_list.xml b/app/src/main/res/layout/item_closed_position_list.xml new file mode 100644 index 0000000000..078b399fb0 --- /dev/null +++ b/app/src/main/res/layout/item_closed_position_list.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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..117c03984c --- /dev/null +++ b/app/src/main/res/layout/item_market_list.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + 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..ad069496c0 --- /dev/null +++ b/app/src/main/res/layout/item_total_position_value.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_empty_transaction.xml b/app/src/main/res/layout/layout_empty_transaction.xml index 2acf54477e..0be7fb938e 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 3dce5a2f84..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 二维码,点击识别 @@ -470,6 +470,9 @@ 错误 10614: 输入金额太小或太大,请重新输入。 错误 10614: 金额超出最大下单金额 %1$s,请重新输入。试试限价?不受金额和币种限制。 错误 10615: 暂不支持该交易对,请尝试切换币种。 + 错误 10650: 仓位规模太小,请调整后重试。 + 错误 10651:当前已有持仓 + 当前市场已开仓。 错误 20114:验证码已过期 错误 20113:验证码错误 你已经尝试了超过 5 次,请等待 24 小时后再次尝试。 @@ -574,6 +577,7 @@ %d 小时 %d 小时 + %1$d分钟 %1$d小时 %1$d天 %1$d周 @@ -630,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 绝不会访问您的隐私信息,也不会与第三方共享。 忙线未接听 对方忙 链接桌面端 @@ -936,6 +951,7 @@ 搜索资产,联系人,消息 搜索消息 搜索名称, 符号 + 搜索市场 名称, 符号, 链接 名称 搜索 Mixin ID 或手机号码: @@ -1597,7 +1613,7 @@ 滑点 自动 根据不同币种推荐合适的滑点,帮助你交易成功 - 自定义 + 自定义 将按照您设置的滑点执行交易 您的交易可能会抢先交易并导致不利的交易 滑点不可大于 50% 且不可小于 0.1% @@ -1613,6 +1629,8 @@ 最低金额 %1$s %2$s 最高金额 %1$s %2$s 金额范围 %1$s %2$s ~ %3$s %4$s + 最小保证金为 %1$s + 最大保证金为 %1$s 授权 借款 @@ -1684,6 +1702,7 @@ 暂无价格数据 余额 行情 + 市场 行情 查看全部 我的余额 @@ -1722,6 +1741,7 @@ 我们定期更新应用程序,以便为您提供更好的服务 立即更新 市值 + #%1$d 全球总市值 该签名请求来自 %1$s。 拒绝 @@ -1757,7 +1777,6 @@ 价格预警 编辑价格预警 没有价格预警 - 添加价格预警 当前价格:%1$s 预警类型 预警频率 @@ -2220,10 +2239,154 @@ 未找到可用报价。请尝试其他代币或金额。 Safe 金库、观察钱包和隐藏的资产不计入统计 共管的 Safe 金库不计入统计 + 交易说明 + 简介 + <%1$s + %1$s> + 永续合约允许您使用杠杆交易加密货币,从而放大您的潜在利润(和损失)。您可以做多(押注价格上涨)或做空(押注价格下跌),而无需拥有标的资产。 + 做多意味着您预期价格会上涨。如果价格上涨,您就会获利。如果价格下跌,您就会亏损。您的盈亏会被杠杆倍数放大。 + 做空意味着您预期价格会下跌。如果价格下跌,您就会获利。如果价格上涨,您就会亏损。您的盈亏会被杠杆倍数放大。 + 杠杆允许您用更少的资金控制更大的仓位。例如,使用 10 倍杠杆,1%% 的价格变动会导致 10%% 的盈亏。杠杆越高,风险越大。 + 您可以随时平仓以实现盈亏。平仓价格基于当前市场价格。请务必监控您的仓位以避免爆仓。 + 具体说明 + Mixin 永续合约是一种以数字资产结算的衍生品交易方式,支持做多和做空,无到期日。通过杠杆机制,交易者可以放大仓位,把握价格上涨或下跌带来的交易机会。 + 产品特点: + 无到期日,可长期持仓 + 支持做多 / 做空,双向交易 + 最高支持 %d 倍杠杆 + 支持逐仓模式,灵活控制风险 + 风险提示: + 杠杆交易可能放大收益,同时也会放大亏损 + 当保证金不足时,仓位可能被强制平仓 + 请合理控制杠杆倍数与仓位规模,谨慎交易 + 举例说明 + 开仓交易 + 投入资金 + 具体说明 + 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 + 价格上涨 + 价格下跌 + 上涨幅度 + 下跌幅度 + 盈亏 + 做多是指在预期价格上涨时建立仓位,价格上涨可获得盈利,价格下跌则产生亏损。 + 做空是指在预期价格下跌时建立仓位,价格下跌可获得盈利,价格上涨则产生亏损。 + 产生盈利 + 产生亏损 + 盈亏规则: + 杠杆倍数用于放大交易规模,以较少的保证金控制更大的合约仓位。 + 盈亏影响: + 杠杆会同时放大收益和亏损 + 杠杆倍数越高,盈亏随价格波动的变化越大 + 请合理选择杠杆倍数,高杠杆下,价格小幅波动也可能导致较大亏损。 + 当前合约的仓位规模由「投入资金 × 杠杆倍数」计算得出,表示本次交易所控制的资产规模。 + 用途: + 决定本次交易的市场敞口规模 + 影响盈亏变化的放大倍数 + 支撑当前仓位 + 抵扣浮动亏损 + 当亏损接近已投入资金时,可能被系统强制平仓。 + 价格剧烈波动可能会快速消耗投入资金。 + 交易币种 + 支付金额 + 兑换价格 + 交易策略 + 成交价格 + 市场价格 + 适合场景 + 报价说明 + 闪兑是一种操作简单、按当前最优市场价格立即成交的兑换方式。 + 希望立即成交,追求操作效率和确定性。 + 兑换主流币或流动性充足的交易对,市价成交通常不会产生明显价差。 + 新手友好,操作简单、直观。 + 交易报价为 DEX 和 CEX 聚合最优价。 + 交易报价已包含交易所手续费和网络提现手续费。 + 市场剧烈波动时,实际成交价格可能与预期有差异。 + 低价买入\n高价卖出 + 低价买入 + 高价卖出 + 专业模式支持限价挂单,这是一种由用户自行设定成交价格,在市场价格达到预期时才会成交的交易方式。 + 有明确交易策略,例如区间交易、低买高卖等。 + 对成交价格敏感,希望严格按照指定价格或更优价格成交。 + 进行大额交易,希望通过挂单方式逐步成交,避免对市场造成冲击或产生明显滑点。 + 不急于立即成交,希望在价格到达预期区间后,由系统自动撮合完成交易。 + 市场剧烈波动时,可能会出现快速跳过挂单价格,导致无法成交。 + 永续合约 + 开仓 + 杠杆 + 选择代币 + 选择杠杆 + 仓位规模 + 清算价格 + 价格%1$s %2$s%% → 盈利 %3$s%4$s%% (%5$s%6$s) + 做多 + 做空 + 方向 + 开仓价格 + 价格 + 最新价格 + 平仓价格 + 数量 + 持仓详情 + 持仓概要 + 持仓信息 + 已实现盈亏 + 价格变化 + 时间信息 + 开仓时间 + 平仓时间 + Mixin Futures + 确认开仓 + 开仓成功 + 确认平仓 + 平仓成功 + 平仓 + 开仓做多 + 开仓做空 + 平仓做多 + 平仓做空 + 预估清算价格 + 仓位总价值 + 保证金 + %1$s %2$s + %1$d倍 + 持仓(%d) + 持仓 + 持仓 + 历史记录 + 暂无仓位 + 暂无历史记录 + 盈亏 + 开仓方向 + 暂无行情 + 查看更多 + 加载中... + 交易量:%1$s + 开仓: $%1$s → 平仓: $%2$s + 永续合约如何运作? + 了解如何交易永续合约 + 24小时成交量 + 未平仓量 + 资金费率 + 选择市场 搜索… 未检测到sim卡。 选择一个国家或地区 匿名号码 + 市场 忘记 PIN 切换账号 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 03e7bed8c4..b6e3a9b7d4 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 on your behalf. Amount + Margin an audio Anonymous Number ANSWER @@ -371,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 @@ -484,6 +485,9 @@ 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: Position size is too small. + 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 You have tried more than 5 times, please wait at least 24 hours to try again. @@ -590,6 +594,7 @@ %d Hour %d Hours + %1$dm %1$dH %1$dD %1$dW @@ -652,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 @@ -965,6 +981,7 @@ assets, contacts and messages SEARCH MESSAGES Search Name, Symbol + Search Market Name, Symbol, URL Name Search Mixin ID or phone number: @@ -1648,7 +1665,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% @@ -1664,6 +1681,8 @@ Minimum amount %1$s %2$s Maximum amount %1$s %2$s Amount must be between %1$s %2$s and %3$s %4$s + The minimum margin is %1$s + The maximum margin is %1$s Approve Borrow @@ -1733,6 +1752,7 @@ Price data unavailable Balance Market + All Markets Markets View All My Balance @@ -1771,6 +1791,7 @@ We update the app regularly so we can make it better for you Update Now Market Cap + #%1$d Market Cap Approve Reject @@ -1806,7 +1827,6 @@ Alert Edit Alert NO ALERTS - Add Alert Current price: %1$s Alert Type Frequency @@ -2285,10 +2305,157 @@ 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 + <%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. + 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. + 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, allowing positions to be held long-term + Support long/short, bidirectional trading + Up to %1$dx leverage supported + 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 + 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$d: %2$s + Price Rise + Price Fall + Upward Change + Downward Change + P&L + 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 + PnL Rules: + Leverage is used to amplify trading size, controlling larger contract positions with less margin. + P&L Impact + Leverage amplifies both profits and losses + Higher leverage means greater P&L fluctuation with price movements. + 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. + 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. + 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 + Select Token + Select Leverage + Size + Liquidation Price + Price %1$s %2$s%% → Profit %3$s%4$s%% (%5$s%6$s) + Long + Short + Direction + Entry Price + Mark Price + Latest Price + Close Price + Quantity + Position Details + Position Summary + Position Info + Total Realized PnL + Price Change + Time Info + Open Time + Close Time + Mixin Futures + Wallet not found + How perps works? + Learn how to trade perps + 24H VOLUME + Open Interest + Funding Rate + Confirm Opening + Open Position Success + Close Position + Confirm Close Position + Close Position Success + Opened Long + Opened Short + Closed Long + Closed Short + Estimated Liquidation Price + Total Position Value + %1$s %2$s + %1$dx + $%1$f + %1$s$%2$f + %1$s(%2$.2f%%) + Positions(%d) + Positions + Position + Activity + No Position + No Activity + PNL + Direction + No Markets + View More + Loading... + Vol %1$s + Entry: $%1$s → Close: $%2$s + Select Market Search… No SIM card detected. Choose a Country or Region Anonymous Number + Markets Forgot PIN Switch Account diff --git a/gradle.properties b/gradle.properties index e2223f3ad2..77de437f2e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,4 +25,5 @@ kotlin.caching.enabled=true android.r8.optimizedResourceShrinking=true android.sdk.defaultTargetSdkToCompileSdkIfUnset=false android.uniquePackageNames=false +android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false