Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
8dd1d36
Trade spot guide, refine wallet onboarding guides and spot trade limi…
SeniorZhai Mar 16, 2026
26c5b84
Add pinch zoom to candle chart
SeniorZhai Mar 19, 2026
adb780b
Update guide style
SeniorZhai Mar 24, 2026
a68ed60
Add elevation to Button and create MixinButton component
SeniorZhai Mar 25, 2026
e625f6a
refactor: reuse mixin compose button
SeniorZhai Mar 25, 2026
4d59c47
style: drop explicit normal button weights
SeniorZhai Mar 25, 2026
262d179
fix: exclude zero-volume perps markets
SeniorZhai Mar 25, 2026
b1bc4fb
fix: convert perps market change to percentage correctly
SeniorZhai Mar 25, 2026
e3a451e
fix: use upsert to preserve rowid in perps tables
SeniorZhai Mar 25, 2026
38d5ef5
refactor: unify perps market change percent calculation and formatting
SeniorZhai Mar 25, 2026
2a46fa8
Merge branch 'fix/button-elevation' into feature/trde_guide
SeniorZhai Mar 25, 2026
77a0081
Update strings
SeniorZhai Mar 25, 2026
ea18228
Revert small screen layout
SeniorZhai Mar 25, 2026
6edfd9e
Merge branch 'fix/button-elevation' into feature/trde_guide
SeniorZhai Mar 25, 2026
4511d64
feat: auto-set limit price 1% below market price
SeniorZhai Mar 25, 2026
f6757ab
Merge branch 'master' into feature/trde_guide
crossle Mar 25, 2026
c6809bc
fix limit order price shortcut direction
SeniorZhai Mar 26, 2026
a2033f0
refactor perps open position card layout
SeniorZhai Mar 26, 2026
9eea336
Merge branch 'codex/perps-open-position-card-layout' into feature/trd…
SeniorZhai Mar 26, 2026
b1013b9
fix candle chart drag after zoom
SeniorZhai Mar 26, 2026
add5726
adjust candle chart price precision
SeniorZhai Mar 26, 2026
ef32d61
update guide navigation buttons
SeniorZhai Mar 26, 2026
d842b0c
Merge branch 'master' into feature/trde_guide
crossle Mar 26, 2026
7f4ab6e
adjust spot guide example amounts
SeniorZhai Mar 26, 2026
450a1d3
Update position card
SeniorZhai Mar 26, 2026
587961c
fix web header icon for fixed title
SeniorZhai Mar 26, 2026
14f9cf5
Update spot button
SeniorZhai Mar 26, 2026
9543956
Update font weight
SeniorZhai Mar 26, 2026
e3f9c97
Update title
SeniorZhai Mar 26, 2026
10c55d4
show trading guides once per category
SeniorZhai Mar 26, 2026
239a409
Merge branch 'master' into feature/trde_guide
crossle Mar 26, 2026
3ff0d8b
Update padding
SeniorZhai Mar 26, 2026
4b8d482
fix button padding
crossle Mar 26, 2026
732a88e
Revert "fix button padding"
crossle Mar 26, 2026
351a264
Update padding
SeniorZhai Mar 26, 2026
513285a
customize trade guide menu titles
SeniorZhai Mar 26, 2026
8e8d54a
Update padding
SeniorZhai Mar 26, 2026
e645a70
Update horizontal arrangement
SeniorZhai Mar 26, 2026
6c2b72b
Update horizontal arrangement
SeniorZhai Mar 26, 2026
fd544da
fix help menu text
crossle Mar 26, 2026
83f5712
fix typo
crossle Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -815,8 +815,8 @@ fun InputAmountPreviewScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 50.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(28.dp)
.padding(vertical = 16.dp, horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(28.dp, Alignment.CenterHorizontally)
) {
if (invoiceUri != null) {
ActionButton(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package one.mixin.android.ui.home.web3.components



import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
Expand All @@ -17,13 +16,15 @@ import one.mixin.android.api.response.web3.SwapToken
import one.mixin.android.compose.theme.MixinAppTheme
import one.mixin.android.ui.home.web3.trade.FocusedField
import java.math.BigDecimal
import java.math.RoundingMode

@Composable
fun FloatingActions(
focusedField: FocusedField,
fromBalance: String?,
fromToken: SwapToken?,
toToken: SwapToken?,
isPriceInverted: Boolean,
onSetInput: (String) -> Unit,
onSetPriceMultiplier: (Float?) -> Unit,
onDone: () -> Unit,
Expand Down Expand Up @@ -77,17 +78,27 @@ fun FloatingActions(
onMarketPriceClick?.invoke()
}

val isFromUsd = fromToken?.assetId?.let { id ->
Constants.AssetId.usdtAssets.containsKey(id) || Constants.AssetId.usdcAssets.containsKey(id)
} == true
val isToUsd = toToken?.assetId?.let { id ->
Constants.AssetId.usdtAssets.containsKey(id) || Constants.AssetId.usdcAssets.containsKey(id)
} == true

if (isToUsd) {
InputAction("+10%", showBorder = true) { onSetPriceMultiplier(1.1f) }
InputAction("+20%", showBorder = true) { onSetPriceMultiplier(1.2f) }
if (isToUsd && !isFromUsd) {
InputAction("+10%", showBorder = true) {
onSetPriceMultiplier(displayPriceMultiplier(1.1f, isPriceInverted))
}
InputAction("+20%", showBorder = true) {
onSetPriceMultiplier(displayPriceMultiplier(1.2f, isPriceInverted))
}
} else {
// from is USD or other cases -> -10% / -20%
InputAction("-10%", showBorder = true) { onSetPriceMultiplier(0.9f) }
InputAction("-20%", showBorder = true) { onSetPriceMultiplier(0.8f) }
InputAction("-10%", showBorder = true) {
onSetPriceMultiplier(displayPriceMultiplier(0.9f, isPriceInverted))
}
InputAction("-20%", showBorder = true) {
onSetPriceMultiplier(displayPriceMultiplier(0.8f, isPriceInverted))
}
}
InputAction(stringResource(R.string.Done), showBorder = false) { onDone() }
}
Expand All @@ -96,3 +107,10 @@ fun FloatingActions(
}
}

private fun displayPriceMultiplier(displayMultiplier: Float, isPriceInverted: Boolean): Float {
if (!isPriceInverted) return displayMultiplier

return BigDecimal.ONE
.divide(BigDecimal(displayMultiplier.toString()), 8, RoundingMode.HALF_UP)
.toFloat()
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ fun PriceInputArea(
toToken: SwapToken?,
lastOrderTime: Long?,
priceMultiplier: Float?,
isPriceInverted: Boolean,
onPriceInvertedChange: (Boolean) -> Unit,
onStandardPriceChanged: (String) -> Unit,
) {
val viewModel = hiltViewModel<SwapViewModel>()

val context = LocalContext.current

var isPriceInverted by remember { mutableStateOf(false) }

// Display price shown in the input field, initialized from market price
var displayPrice by remember { mutableStateOf("") }
Expand All @@ -66,7 +66,7 @@ fun PriceInputArea(
val isToUsd = toToken?.assetId?.let { id ->
Constants.AssetId.usdtAssets.containsKey(id) || Constants.AssetId.usdcAssets.containsKey(id)
} == true
isPriceInverted = isFromUsd && !isToUsd
onPriceInvertedChange(isFromUsd && !isToUsd)
}

LaunchedEffect(priceMultiplier) {
Expand Down Expand Up @@ -216,7 +216,7 @@ fun PriceInputArea(
tint = MixinAppTheme.colors.textAssist,
modifier = Modifier
.size(16.dp)
.clickable { isPriceInverted = !isPriceInverted }
.clickable { onPriceInvertedChange(!isPriceInverted) }
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,8 @@ fun ActionButton(
Text(
text = text,
color = if (enabled) contentColor else disabledContentColor,
fontSize = 16.sp
fontSize = 16.sp,
fontWeight = FontWeight.W400
)
}
}
Expand Down
176 changes: 160 additions & 16 deletions app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand All @@ -27,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -38,6 +41,8 @@ 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.PointerEvent
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
Expand Down Expand Up @@ -65,10 +70,61 @@ 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.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt

private const val CANDLE_REFRESH_INTERVAL_MS = 10_000L
private const val DEFAULT_CANDLE_SCALE = 1f
private const val MIN_CANDLE_SCALE = 0.5f
private const val MAX_CANDLE_SCALE = 3f

private fun PointerEvent.currentPressedChanges(): List<PointerInputChange> =
changes.filter { it.pressed }

private fun PointerEvent.compatCalculateCentroid(): Offset {
val pressed = currentPressedChanges()
if (pressed.isEmpty()) return Offset.Zero

val x = pressed.sumOf { it.position.x.toDouble() } / pressed.size
val y = pressed.sumOf { it.position.y.toDouble() } / pressed.size
return Offset(x.toFloat(), y.toFloat())
}

private fun PointerEvent.compatCalculatePan(): Offset {
val pressed = currentPressedChanges()
if (pressed.isEmpty()) return Offset.Zero

val currentCentroid = compatCalculateCentroid()
val previousX = pressed.sumOf { it.previousPosition.x.toDouble() } / pressed.size
val previousY = pressed.sumOf { it.previousPosition.y.toDouble() } / pressed.size
val previousCentroid = Offset(previousX.toFloat(), previousY.toFloat())
return currentCentroid - previousCentroid
}

private fun PointerEvent.compatCalculateZoom(): Float {
val pressed = currentPressedChanges()
if (pressed.size < 2) return 1f

val currentCentroid = compatCalculateCentroid()
val previousX = pressed.sumOf { it.previousPosition.x.toDouble() } / pressed.size
val previousY = pressed.sumOf { it.previousPosition.y.toDouble() } / pressed.size
val previousCentroid = Offset(previousX.toFloat(), previousY.toFloat())

val currentAverageDistance = pressed
.map { hypot((it.position.x - currentCentroid.x).toDouble(), (it.position.y - currentCentroid.y).toDouble()) }
.average()
.toFloat()
val previousAverageDistance = pressed
.map { hypot((it.previousPosition.x - previousCentroid.x).toDouble(), (it.previousPosition.y - previousCentroid.y).toDouble()) }
.average()
.toFloat()

return if (previousAverageDistance > 0f) currentAverageDistance / previousAverageDistance else 1f
}

@Composable
fun CandleChart(
Expand Down Expand Up @@ -157,16 +213,24 @@ private fun ScrollableCandleChart(
val items = candleView.items
if (items.isEmpty()) return

val candleWidth = 6.dp
val spacing = 2.dp
val baseCandleWidth = 6.dp
val baseSpacing = 2.dp
val density = LocalDensity.current

val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
var touchXOnChart by remember { mutableStateOf<Float?>(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()
Expand Down Expand Up @@ -215,13 +279,14 @@ private fun ScrollableCandleChart(
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 priceScale = resolveChartPriceScale(maxPrice, minPrice, midPrice)
val maxPriceText = formatPrice(maxPrice, priceScale)
val midPriceText = formatPrice(midPrice, priceScale)
val minPriceText = formatPrice(minPrice, priceScale)

val selectedPrice = selectedItem?.close?.toBigDecimalOrNull()
val showCurrentPrice = selectedPrice == null && latestPrice != null
val currentPriceText = latestPrice?.let { formatPrice(it) }
val currentPriceText = latestPrice?.let { formatPrice(it, priceScale) }
val isCurrentPriceInRange = latestPrice?.let { it >= minPrice && it <= maxPrice } == true
val isCurrentPriceOverlapping = currentPriceText != null &&
currentPriceText in setOf(maxPriceText, midPriceText, minPriceText)
Expand All @@ -232,7 +297,66 @@ private fun ScrollableCandleChart(
modifier = Modifier
.fillMaxSize()
.padding(end = axisPanelWidth)
.pointerInput(items.size, scrollState.value) {
.pointerInput(
items.size,
viewportWidthPx,
chartStartPaddingPx,
baseCandleWidthPx,
baseSpacingPx,
) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var gestureHandled = false

do {
val event = awaitPointerEvent()
val zoom = event.compatCalculateZoom()
val pan = event.compatCalculatePan()
val centroid = event.compatCalculateCentroid()
val pressedCount = event.changes.count { it.pressed }

if (pressedCount > 1 && (abs(zoom - 1f) >= 0.0001f || abs(pan.x) >= 0.0001f)) {
gestureHandled = true
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)
}

event.changes.forEach { change ->
if (change.pressed) {
change.consume()
}
}
}
} while (event.changes.any { it.pressed })

if (gestureHandled) {
isPinching = false
}
}
}
.pointerInput(items.size, totalChartWidthPx, isPinching) {
if (isPinching) return@pointerInput
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
isTouching = true
Expand All @@ -254,7 +378,7 @@ private fun ScrollableCandleChart(
}
)
}
.horizontalScroll(scrollState, enabled = !isTouching)
.horizontalScroll(scrollState, enabled = !isTouching && !isPinching)
.clipToBounds()
Comment on lines 300 to 382
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isPinching is set to true inside detectTransformGestures, but it is never reset to false. After the first pan/zoom gesture, the long-press crosshair (pointerInput below) and horizontalScroll remain disabled permanently (enabled = !isTouching && !isPinching). Reset the flag when the transform gesture ends/cancels (or switch to transformable and use its in-progress state) so other interactions keep working.

Copilot uses AI. Check for mistakes.
) {
PerpsCandleChartCanvas(
Expand Down Expand Up @@ -336,7 +460,7 @@ private fun ScrollableCandleChart(
contentAlignment = Alignment.CenterEnd
) {
Text(
text = formatPrice(currentPrice),
text = formatPrice(currentPrice, priceScale),
fontSize = 10.sp,
color = MixinAppTheme.colors.textPrimary,
textAlign = TextAlign.End,
Expand Down Expand Up @@ -374,7 +498,7 @@ private fun ScrollableCandleChart(
contentAlignment = Alignment.CenterEnd
) {
Text(
text = formatPrice(selectedPrice),
text = formatPrice(selectedPrice, priceScale),
fontSize = 10.sp,
color = MixinAppTheme.colors.textPrimary,
textAlign = TextAlign.End,
Expand Down Expand Up @@ -614,13 +738,33 @@ private fun DrawScope.drawTouchCrosshair(
)
}

private fun formatPrice(price: BigDecimal): String {
val scaledPrice = when {
price >= BigDecimal("100") -> price.setScale(0, java.math.RoundingMode.HALF_UP)
price >= BigDecimal("1") -> price.setScale(2, java.math.RoundingMode.HALF_UP)
else -> price.setScale(6, java.math.RoundingMode.HALF_UP)
private fun resolveChartPriceScale(
maxPrice: BigDecimal,
minPrice: BigDecimal,
midPrice: BigDecimal,
): Int {
if (maxPrice < BigDecimal.ONE && minPrice < BigDecimal.ONE) {
return 6
}
return scaledPrice.stripTrailingZeros().toPlainString()

var scale = 2
while (scale < 4) {
val maxText = formatPrice(maxPrice, scale)
val minText = formatPrice(minPrice, scale)
val midText = formatPrice(midPrice, scale)
if (setOf(maxText, minText, midText).size == 3) {
break
}
scale++
}
return scale
}

private fun formatPrice(
price: BigDecimal,
scale: Int,
): String {
return price.setScale(scale, java.math.RoundingMode.HALF_UP).toPlainString()
}

private fun formatCandleTime(timestamp: Long, timeFrame: String): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,3 @@ fun ClosedPositionItem(
}
}

private const val SMALL_SCREEN_WIDTH_DP = 360
Loading
Loading