Skip to content

Commit 5bad0f0

Browse files
SeniorZhaicrossle
andauthored
feat(market): add long short buttons (#6492)
* feat(market): add perps long short actions * fix(market): adjust perps action button sizing * fix(market): refine perps action behavior and style * fix(market): adjust perps action content alignment --------- Co-authored-by: Crossle Song <crosslesong@gmail.com>
1 parent 95de687 commit 5bad0f0

13 files changed

Lines changed: 3715 additions & 3 deletions

File tree

app/schemas/one.mixin.android.db.MixinDatabase/71.json

Lines changed: 3356 additions & 0 deletions
Large diffs are not rendered by default.

app/src/main/java/one/mixin/android/Constants.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ object Constants {
220220
object DataBase {
221221
const val DB_NAME = "mixin.db"
222222
const val MINI_VERSION = 15
223-
const val CURRENT_VERSION = 70
223+
const val CURRENT_VERSION = 71
224224

225225
const val FTS_DB_NAME = "fts.db"
226226
const val PENDING_DB_NAME = "pending.db"

app/src/main/java/one/mixin/android/db/MixinDatabase.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_66_67
7070
import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_67_68
7171
import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_68_69
7272
import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_69_70
73+
import one.mixin.android.db.MixinDatabaseMigrations.Companion.MIGRATION_70_71
7374
import one.mixin.android.db.converter.DepositEntryListConverter
7475
import one.mixin.android.db.converter.FiatOrderConverter
7576

@@ -427,6 +428,7 @@ abstract class MixinDatabase : RoomDatabase() {
427428
MIGRATION_67_68,
428429
MIGRATION_68_69,
429430
MIGRATION_69_70,
431+
MIGRATION_70_71,
430432
)
431433
.enableMultiInstanceInvalidation()
432434
.setQueryExecutor(

app/src/main/java/one/mixin/android/db/MixinDatabaseMigrations.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,13 @@ class MixinDatabaseMigrations private constructor() {
604604
}
605605
}
606606

607+
val MIGRATION_70_71: Migration =
608+
object : Migration(70, 71) {
609+
override fun migrate(db: SupportSQLiteDatabase) {
610+
db.execSQL("ALTER TABLE `markets` ADD COLUMN `perps_market_id` TEXT")
611+
}
612+
}
613+
607614
// If you add a new table, be sure to add a clear method to the DatabaseUtil
608615
}
609616
}

app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualViewModel.kt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,33 @@ class PerpetualViewModel @Inject constructor(
154154
}
155155
}
156156

157+
suspend fun getMarketById(marketId: String): PerpsMarket? {
158+
val cachedMarket = withContext(Dispatchers.IO) {
159+
perpsMarketDao.getMarket(marketId)
160+
}
161+
if (cachedMarket != null) return cachedMarket
162+
163+
return try {
164+
val response = withContext(Dispatchers.IO) {
165+
routeService.getPerpsMarket(marketId)
166+
}
167+
val data = response.data
168+
if (response.isSuccess && data != null) {
169+
val normalizedMarket = data.withDefaults()
170+
withContext(Dispatchers.IO) {
171+
perpsMarketDao.insert(normalizedMarket)
172+
}
173+
normalizedMarket
174+
} else {
175+
Timber.e("Failed to load perps market for $marketId: ${response.errorDescription}")
176+
null
177+
}
178+
} catch (e: Exception) {
179+
Timber.e(e, "Error loading perps market for $marketId")
180+
null
181+
}
182+
}
183+
157184
fun observeMarkets(): Flow<List<PerpsMarket>> {
158185
return perpsMarketDao.observeAllMarkets()
159186
}

app/src/main/java/one/mixin/android/ui/wallet/MarketDetailsFragment.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import one.mixin.android.Constants.AssetId.USDT_ASSET_ETH_ID
1717
import one.mixin.android.Constants.AssetId.XIN_ASSET_ID
1818
import one.mixin.android.R
1919
import one.mixin.android.RxBus
20+
import one.mixin.android.api.response.perps.PerpsMarket
21+
import one.mixin.android.compose.theme.MixinAppTheme
2022
import one.mixin.android.databinding.FragmentDetailsMarketBinding
2123
import one.mixin.android.event.TradeMarketSelectedEvent
2224
import one.mixin.android.extension.colorAttr
@@ -37,12 +39,15 @@ import one.mixin.android.extension.setQuoteTextWithBackgroud
3739
import one.mixin.android.extension.toast
3840
import one.mixin.android.job.MixinJobManager
3941
import one.mixin.android.job.RefreshMarketJob
42+
import one.mixin.android.session.Session
4043
import one.mixin.android.ui.common.BaseFragment
4144
import one.mixin.android.ui.home.market.Market
4245
import one.mixin.android.ui.home.web3.market.DepositTokensBottomSheetDialogFragment
4346
import one.mixin.android.ui.home.web3.trade.TradeFragment
4447
import one.mixin.android.ui.home.web3.trade.TradeFragment.Companion.ARGS_INPUT
4548
import one.mixin.android.ui.home.web3.trade.TradeFragment.Companion.ARGS_OUTPUT
49+
import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity
50+
import one.mixin.android.ui.home.web3.trade.perps.PerpetualViewModel
4651
import one.mixin.android.ui.wallet.alert.AlertFragment
4752
import one.mixin.android.ui.wallet.alert.AlertFragment.Companion.ARGS_COIN
4853
import one.mixin.android.ui.wallet.alert.AlertFragment.Companion.ARGS_GO_ALERT
@@ -75,6 +80,7 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) {
7580
lateinit var jobManager: MixinJobManager
7681

7782
private val walletViewModel by viewModels<WalletViewModel>()
83+
private val perpetualViewModel by viewModels<PerpetualViewModel>()
7884

7985
private val marketItem: MarketItem by lazy {
8086
requireNotNull(requireArguments().getParcelableCompat(ARGS_MARKET, MarketItem::class.java))
@@ -174,6 +180,7 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) {
174180
}
175181
}
176182
}
183+
setupPerpsAction(marketItem)
177184
if (marketItem.coinId.isBlank()) {
178185
walletViewModel.anyAlertByAssetId(marketItem.assetIds!!.first())
179186
} else {
@@ -320,6 +327,7 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) {
320327
}.observe(this.viewLifecycleOwner) { info ->
321328
if (info != null) {
322329
loadBalance(info)
330+
setupPerpsAction(info)
323331
binding.apply {
324332
assetRank.isVisible = true
325333
titleView.rightExtraIb.isVisible = true
@@ -409,6 +417,75 @@ class MarketDetailsFragment : BaseFragment(R.layout.fragment_details_market) {
409417
}
410418

411419
private var isLoading = false
420+
private var currentPerpsMarketId: String? = null
421+
422+
private fun setupPerpsAction(marketItem: MarketItem) {
423+
val perpsMarketId = marketItem.perpsMarketId?.takeIf { it.isNotBlank() }
424+
if (perpsMarketId == null) {
425+
currentPerpsMarketId = null
426+
binding.perpsAction.isVisible = false
427+
return
428+
}
429+
if (perpsMarketId == currentPerpsMarketId && binding.perpsAction.isVisible) return
430+
currentPerpsMarketId = perpsMarketId
431+
binding.perpsAction.isVisible = false
432+
viewLifecycleOwner.lifecycleScope.launch {
433+
val perpsMarket = perpetualViewModel.getMarketById(perpsMarketId)
434+
if (!isAdded || currentPerpsMarketId != perpsMarketId) return@launch
435+
binding.perpsAction.isVisible = perpsMarket != null
436+
if (perpsMarket != null) {
437+
binding.perpsAction.setContent {
438+
MixinAppTheme {
439+
MarketPerpsAction(
440+
onLongClick = {
441+
openPerpsAction(perpsMarket, true)
442+
},
443+
onShortClick = {
444+
openPerpsAction(perpsMarket, false)
445+
},
446+
)
447+
}
448+
}
449+
}
450+
}
451+
}
452+
453+
private fun openPerpsAction(market: PerpsMarket, isLong: Boolean) {
454+
if (!isAdded) return
455+
viewLifecycleOwner.lifecycleScope.launch {
456+
val walletId = Session.getAccountId()
457+
val hasOpenPosition = if (walletId.isNullOrEmpty()) {
458+
false
459+
} else {
460+
perpetualViewModel.getOpenPositionsFromDb(walletId).any { it.marketId == market.marketId }
461+
}
462+
if (!isAdded) return@launch
463+
if (hasOpenPosition) {
464+
PerpsActivity.showDetail(
465+
context = requireContext(),
466+
marketId = market.marketId,
467+
marketSymbol = market.displaySymbol,
468+
marketDisplaySymbol = market.displaySymbol,
469+
marketTokenSymbol = market.tokenSymbol,
470+
source = AnalyticsTracker.PerpsSource.SPOT_MARKET_DETAIL,
471+
)
472+
} else {
473+
openPerpsPosition(market, isLong)
474+
}
475+
}
476+
}
477+
478+
private fun openPerpsPosition(market: PerpsMarket, isLong: Boolean) {
479+
PerpsActivity.showOpenPosition(
480+
context = requireContext(),
481+
marketId = market.marketId,
482+
marketSymbol = market.displaySymbol,
483+
marketDisplaySymbol = market.displaySymbol,
484+
marketTokenSymbol = market.tokenSymbol,
485+
isLong = isLong,
486+
source = AnalyticsTracker.PerpsSource.SPOT_MARKET_DETAIL,
487+
)
488+
}
412489

413490
private fun setBottomAlertLoading(loading: Boolean) {
414491
binding.bottomAlertIcon.isVisible = !loading
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package one.mixin.android.ui.wallet
2+
3+
import androidx.compose.foundation.LocalIndication
4+
import androidx.compose.foundation.background
5+
import androidx.compose.foundation.clickable
6+
import androidx.compose.foundation.interaction.MutableInteractionSource
7+
import androidx.compose.foundation.layout.Box
8+
import androidx.compose.foundation.layout.BoxWithConstraints
9+
import androidx.compose.foundation.layout.Row
10+
import androidx.compose.foundation.layout.Spacer
11+
import androidx.compose.foundation.layout.fillMaxWidth
12+
import androidx.compose.foundation.layout.offset
13+
import androidx.compose.foundation.layout.padding
14+
import androidx.compose.foundation.layout.size
15+
import androidx.compose.foundation.layout.width
16+
import androidx.compose.foundation.layout.wrapContentHeight
17+
import androidx.compose.material.Icon
18+
import androidx.compose.material.Text
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.remember
21+
import androidx.compose.ui.Alignment
22+
import androidx.compose.ui.Modifier
23+
import androidx.compose.ui.draw.clip
24+
import androidx.compose.ui.geometry.Size
25+
import androidx.compose.ui.graphics.Color
26+
import androidx.compose.ui.graphics.Outline
27+
import androidx.compose.ui.graphics.Path
28+
import androidx.compose.ui.graphics.Shape
29+
import androidx.compose.ui.platform.LocalContext
30+
import androidx.compose.ui.res.painterResource
31+
import androidx.compose.ui.res.stringResource
32+
import androidx.compose.ui.text.font.FontWeight
33+
import androidx.compose.ui.unit.Density
34+
import androidx.compose.ui.unit.LayoutDirection
35+
import androidx.compose.ui.unit.dp
36+
import androidx.compose.ui.unit.sp
37+
import one.mixin.android.Constants
38+
import one.mixin.android.R
39+
import one.mixin.android.compose.theme.MixinAppTheme
40+
import one.mixin.android.extension.defaultSharedPreferences
41+
import kotlin.math.hypot
42+
43+
private val MarketPerpsButtonOverlap = 13.dp
44+
private val MarketPerpsButtonSlant = 27.dp
45+
private val MarketPerpsButtonContentOffset = 6.dp
46+
private val MarketPerpsButtonVerticalPadding = 7.dp
47+
private val MarketPerpsButtonGreenBackground = Color(0xFFDCF2DE)
48+
private val MarketPerpsButtonRedBackground = Color(0xFFFAE4E3)
49+
50+
@Composable
51+
fun MarketPerpsAction(
52+
onLongClick: () -> Unit,
53+
onShortClick: () -> Unit,
54+
modifier: Modifier = Modifier,
55+
) {
56+
val quoteColorReversed = LocalContext.current.defaultSharedPreferences
57+
.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false)
58+
val longColor = if (quoteColorReversed) MixinAppTheme.colors.marketRed else MixinAppTheme.colors.marketGreen
59+
val shortColor = if (quoteColorReversed) MixinAppTheme.colors.marketGreen else MixinAppTheme.colors.marketRed
60+
val longBackgroundColor = if (quoteColorReversed) MarketPerpsButtonRedBackground else MarketPerpsButtonGreenBackground
61+
val shortBackgroundColor = if (quoteColorReversed) MarketPerpsButtonGreenBackground else MarketPerpsButtonRedBackground
62+
63+
BoxWithConstraints(
64+
modifier = modifier
65+
.fillMaxWidth()
66+
.wrapContentHeight(),
67+
) {
68+
val buttonWidth = (maxWidth + MarketPerpsButtonOverlap) / 2f
69+
MarketPerpsButton(
70+
text = stringResource(R.string.Long),
71+
color = longColor,
72+
backgroundColor = longBackgroundColor,
73+
side = MarketPerpsButtonSide.LONG,
74+
onClick = onLongClick,
75+
modifier = Modifier
76+
.align(Alignment.CenterStart)
77+
.width(buttonWidth),
78+
)
79+
MarketPerpsButton(
80+
text = stringResource(R.string.Short),
81+
color = shortColor,
82+
backgroundColor = shortBackgroundColor,
83+
side = MarketPerpsButtonSide.SHORT,
84+
onClick = onShortClick,
85+
modifier = Modifier
86+
.align(Alignment.CenterEnd)
87+
.width(buttonWidth),
88+
)
89+
}
90+
}
91+
92+
@Composable
93+
private fun MarketPerpsButton(
94+
text: String,
95+
color: Color,
96+
backgroundColor: Color,
97+
side: MarketPerpsButtonSide,
98+
onClick: () -> Unit,
99+
modifier: Modifier = Modifier,
100+
) {
101+
val shape = remember(side) { MarketPerpsButtonShape(side) }
102+
Box(
103+
modifier = modifier
104+
.clip(shape)
105+
.background(backgroundColor)
106+
.clickable(
107+
interactionSource = remember { MutableInteractionSource() },
108+
indication = LocalIndication.current,
109+
onClick = onClick,
110+
),
111+
contentAlignment = Alignment.Center,
112+
) {
113+
Row(
114+
modifier = Modifier
115+
.padding(
116+
start = if (side == MarketPerpsButtonSide.SHORT) MarketPerpsButtonSlant else 0.dp,
117+
top = MarketPerpsButtonVerticalPadding,
118+
end = if (side == MarketPerpsButtonSide.LONG) MarketPerpsButtonSlant else 0.dp,
119+
bottom = MarketPerpsButtonVerticalPadding,
120+
)
121+
.offset(
122+
x = if (side == MarketPerpsButtonSide.LONG) {
123+
MarketPerpsButtonContentOffset
124+
} else {
125+
-MarketPerpsButtonContentOffset
126+
},
127+
),
128+
verticalAlignment = Alignment.CenterVertically,
129+
) {
130+
Icon(
131+
painter = painterResource(
132+
if (side == MarketPerpsButtonSide.LONG) {
133+
R.drawable.ic_market_perps_long
134+
} else {
135+
R.drawable.ic_market_perps_short
136+
}
137+
),
138+
contentDescription = null,
139+
tint = color,
140+
modifier = Modifier.size(24.dp),
141+
)
142+
Spacer(modifier = Modifier.width(8.dp))
143+
Text(
144+
text = text,
145+
color = color,
146+
fontSize = 14.sp,
147+
lineHeight = 16.sp,
148+
fontWeight = FontWeight.Medium,
149+
)
150+
}
151+
}
152+
}
153+
154+
private enum class MarketPerpsButtonSide {
155+
LONG,
156+
SHORT,
157+
}
158+
159+
private class MarketPerpsButtonShape(
160+
private val side: MarketPerpsButtonSide,
161+
) : Shape {
162+
override fun createOutline(
163+
size: Size,
164+
layoutDirection: LayoutDirection,
165+
density: Density,
166+
): Outline {
167+
val radius = with(density) { 8.dp.toPx() }.coerceAtMost(size.height / 2)
168+
val slant = with(density) { MarketPerpsButtonSlant.toPx() }.coerceAtMost(size.width / 3)
169+
val slantRadius = radius.coerceAtMost(slant / 2)
170+
val diagonalLength = hypot(slant.toDouble(), size.height.toDouble()).toFloat()
171+
val diagonalInsetX = slantRadius * slant / diagonalLength
172+
val diagonalInsetY = slantRadius * size.height / diagonalLength
173+
val path = Path().apply {
174+
if (side == MarketPerpsButtonSide.LONG) {
175+
moveTo(radius, 0f)
176+
lineTo(size.width - slantRadius, 0f)
177+
quadraticTo(size.width, 0f, size.width - diagonalInsetX, diagonalInsetY)
178+
lineTo(size.width - slant + diagonalInsetX, size.height - diagonalInsetY)
179+
quadraticTo(size.width - slant, size.height, size.width - slant - slantRadius, size.height)
180+
lineTo(radius, size.height)
181+
quadraticTo(0f, size.height, 0f, size.height - radius)
182+
lineTo(0f, radius)
183+
quadraticTo(0f, 0f, radius, 0f)
184+
} else {
185+
moveTo(slant + slantRadius, 0f)
186+
lineTo(size.width - radius, 0f)
187+
quadraticTo(size.width, 0f, size.width, radius)
188+
lineTo(size.width, size.height - radius)
189+
quadraticTo(size.width, size.height, size.width - radius, size.height)
190+
lineTo(slantRadius, size.height)
191+
quadraticTo(0f, size.height, diagonalInsetX, size.height - diagonalInsetY)
192+
lineTo(slant - diagonalInsetX, diagonalInsetY)
193+
quadraticTo(slant, 0f, slant + slantRadius, 0f)
194+
}
195+
close()
196+
}
197+
return Outline.Generic(path)
198+
}
199+
}

0 commit comments

Comments
 (0)