diff --git a/app/src/main/java/one/mixin/android/compose/InputAmountScreen.kt b/app/src/main/java/one/mixin/android/compose/InputAmountScreen.kt index d5508c6e9a..1bc6fe036e 100644 --- a/app/src/main/java/one/mixin/android/compose/InputAmountScreen.kt +++ b/app/src/main/java/one/mixin/android/compose/InputAmountScreen.kt @@ -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( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/FloatingActions.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/FloatingActions.kt index 4c772fdf3d..1c52f69609 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/FloatingActions.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/FloatingActions.kt @@ -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 @@ -17,6 +16,7 @@ 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( @@ -24,6 +24,7 @@ fun FloatingActions( fromBalance: String?, fromToken: SwapToken?, toToken: SwapToken?, + isPriceInverted: Boolean, onSetInput: (String) -> Unit, onSetPriceMultiplier: (Float?) -> Unit, onDone: () -> Unit, @@ -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() } } @@ -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() +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/PriceInputArea.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/PriceInputArea.kt index 1d99a12354..ee0a026909 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/PriceInputArea.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/PriceInputArea.kt @@ -43,13 +43,13 @@ fun PriceInputArea( toToken: SwapToken?, lastOrderTime: Long?, priceMultiplier: Float?, + isPriceInverted: Boolean, + onPriceInvertedChange: (Boolean) -> Unit, onStandardPriceChanged: (String) -> Unit, ) { val viewModel = hiltViewModel() 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("") } @@ -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) { @@ -216,7 +216,7 @@ fun PriceInputArea( tint = MixinAppTheme.colors.textAssist, modifier = Modifier .size(16.dp) - .clickable { isPriceInverted = !isPriceInverted } + .clickable { onPriceInvertedChange(!isPriceInverted) } ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt index 94be2d930a..8df83ca674 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/components/Review.kt @@ -735,7 +735,8 @@ fun ActionButton( Text( text = text, color = if (enabled) contentColor else disabledContentColor, - fontSize = 16.sp + fontSize = 16.sp, + fontWeight = FontWeight.W400 ) } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt index c4c4338523..1332e45d65 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -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 @@ -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 @@ -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 @@ -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 = + 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( @@ -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(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() @@ -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) @@ -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 @@ -254,7 +378,7 @@ private fun ScrollableCandleChart( } ) } - .horizontalScroll(scrollState, enabled = !isTouching) + .horizontalScroll(scrollState, enabled = !isTouching && !isPinching) .clipToBounds() ) { PerpsCandleChartCanvas( @@ -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, @@ -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, @@ -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 { diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt index acda7b5aa9..a1018a7e8a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/ClosedPositionItem.kt @@ -178,4 +178,3 @@ fun ClosedPositionItem( } } -private const val SMALL_SCREEN_WIDTH_DP = 360 diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt index 228091a9d2..241e579d20 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/HelpBottomSheet.kt @@ -27,6 +27,7 @@ import one.mixin.android.compose.theme.MixinAppTheme @Composable fun HelpBottomSheetContent( hideGuide: Boolean = false, + guideTitle: String = stringResource(R.string.Trading_Guide), onContactSupport: () -> Unit, onTradingGuide: () -> Unit, onDismiss: () -> Unit, @@ -44,7 +45,7 @@ fun HelpBottomSheetContent( if (!hideGuide){ HelpOption( - title = stringResource(R.string.Trading_Guide), + title = guideTitle, onClick = onTradingGuide ) } @@ -70,9 +71,7 @@ private fun HelpOption( ) { 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/LimitOrderContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt index a932388b12..02b496f480 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/LimitOrderContent.kt @@ -141,6 +141,7 @@ fun LimitOrderContent( var limitPriceText by remember { mutableStateOf("") } var marketPriceClickTime by remember { mutableStateOf(lastOrderTime) } var priceMultiplier by remember { mutableStateOf(null) } + var isPriceInverted by remember { mutableStateOf(false) } var isReverse by remember { mutableStateOf(false) } val walletId = if (inMixin) Session.getAccountId()!! else Web3Signer.currentWalletId @@ -375,6 +376,8 @@ fun LimitOrderContent( toToken = toToken, lastOrderTime = marketPriceClickTime, priceMultiplier = priceMultiplier, + isPriceInverted = isPriceInverted, + onPriceInvertedChange = { isPriceInverted = it }, onStandardPriceChanged = { limitPriceText = it }, ) Spacer(modifier = Modifier.height(10.dp)) @@ -591,6 +594,7 @@ fun LimitOrderContent( fromBalance = fromBalance, fromToken = fromToken, toToken = toToken, + isPriceInverted = isPriceInverted, onSetPriceMultiplier = { priceMultiplier = it }, onSetInput = { inputText = it @@ -649,4 +653,3 @@ fun Modifier.verticalScrollbar( } } } - 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..784dd45fe4 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/SpotTradeGuidePage.kt @@ -0,0 +1,844 @@ +package one.mixin.android.ui.home.web3.trade + +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.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.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.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.home.web3.trade.perps.GuideNavigationButton +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.Brief_Introduction), + 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.Spot_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) + ) { + 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.Overview), + description = stringResource(R.string.Spot_Trade_Guide_Overview_Desc), + sections = listOf( + stringResource(R.string.Product_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_Fees) to listOf( + stringResource(R.string.Spot_Trade_Guide_Note_1), + ), + ) + ) +} + +@Composable +private fun SimpleSwapContent() { + SpotTradeExampleCard(limitStrategy = null) + Spacer(modifier = Modifier.height(16.dp)) + TradeGuideInfoCard( + title = stringResource(R.string.Overview), + description = stringResource(R.string.Spot_Trade_Guide_Swap_Desc), + sections = listOf( + stringResource(R.string.Spot_Trade_Guide_Use_Cases) 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_Pricing) to listOf( + stringResource(R.string.Spot_Trade_Guide_Swap_Quote_1), + stringResource(R.string.Spot_Trade_Guide_Swap_Quote_2), + ), + stringResource(R.string.Risk_Notice) 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.Overview), + description = stringResource(R.string.Spot_Trade_Guide_Limit_Desc), + sections = listOf( + stringResource(R.string.Spot_Trade_Guide_Use_Cases) 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.Risk_Notice) 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 isPriceDisplayReversed by remember(limitStrategy) { mutableStateOf(false) } + var strategy by remember(limitStrategy) { mutableStateOf(limitStrategy ?: LimitStrategy.BuyLow) } + var payAmount by remember(limitStrategy, strategy) { + mutableStateOf(defaultPayAmount(limitStrategy, strategy)) + } + var limitPriceOffset by remember(limitStrategy, strategy) { mutableStateOf(BigDecimal.ZERO) } + + val isPairReversed = limitStrategy != null && strategy == LimitStrategy.SellHigh + val fromToken = if (isPairReversed) btcToken else usdtToken + val toToken = if (isPairReversed) usdtToken else btcToken + val amountStep = remember(limitStrategy, strategy, isPairReversed) { + if (limitStrategy != null) { + when (strategy) { + LimitStrategy.BuyLow -> BigDecimal("100") + LimitStrategy.SellHigh -> BigDecimal.ONE + } + } else 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(16.dp)) + if (limitStrategy != null) { + StrategyRow( + strategy = strategy, + onStrategySelected = { strategy = it }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + ExampleValueRow( + title = stringResource(R.string.Trade_Guide_Trading_Pair), + value = { + PairDisplay( + fromToken = fromToken, + toToken = toToken, + fromFallbackSymbol = if (isPairReversed) "BTC" else "USDT", + toFallbackSymbol = if (isPairReversed) "USDT" else "BTC", + ) + }, + ) + Spacer(modifier = Modifier.height(16.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 + }, + ) + }, + ) + if (limitStrategy != null) { + Spacer(modifier = Modifier.height(16.dp)) + ExampleValueRow( + title = stringResource(R.string.Trade_Guide_Limit_Price), + value = { + OrderPriceStepper( + price = effectivePrice, + symbol = usdtToken?.symbol ?: "USDT", + onDecrease = { + limitPriceOffset = (limitPriceOffset - LIMIT_PRICE_STEP) + .max(LIMIT_PRICE_STEP.subtract(limitBasePrice)) + }, + onIncrease = { + limitPriceOffset += LIMIT_PRICE_STEP + }, + ) + } + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.Trade_Guide_Market_Price), + fontSize = 14.sp, + lineHeight = 20.sp, + color = MixinAppTheme.colors.textPrimary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + if (limitStrategy == null) { + PriceSubtitle( + marketPrice = effectivePrice, + isReversed = isPriceDisplayReversed, + onSwitchDirection = { isPriceDisplayReversed = !isPriceDisplayReversed }, + onPriceExpired = { priceRefreshFlag = !priceRefreshFlag }, + ) + } else { + PriceSubtitle( + marketPrice = marketPrice, + isReversed = isPriceDisplayReversed, + onSwitchDirection = { isPriceDisplayReversed = !isPriceDisplayReversed }, + onPriceExpired = { priceRefreshFlag = !priceRefreshFlag }, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + ExampleValueRow( + title = stringResource(R.string.Spot_Trade_Guide_You_Receive), + value = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + painter = painterResource(id = guideTokenIconRes(toToken, if (isPairReversed) "USDT" else "BTC")), + contentDescription = null, + tint = Color.Unspecified, + 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_Strategy), + value = { + Row(modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(MixinAppTheme.colors.backgroundWindow) + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LimitStrategy.entries.forEach { item -> + val selected = item == strategy + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background( + if (selected) MixinAppTheme.colors.accent + else Color.Transparent + ) + .clickable { onStrategySelected(item) } + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(item.titleRes), + color = if (selected) Color.White else MixinAppTheme.colors.textAssist, + 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 PairDisplay( + fromToken: TokenItem?, + toToken: TokenItem?, + fromFallbackSymbol: String, + toFallbackSymbol: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + GuideTokenBadge(token = fromToken, fallbackSymbol = fromFallbackSymbol) + Text( + text = "->", + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + GuideTokenBadge(token = toToken, fallbackSymbol = toFallbackSymbol) + } +} + +@Composable +private fun GuideTokenBadge( + token: TokenItem?, + fallbackSymbol: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + painter = painterResource(id = guideTokenIconRes(token, fallbackSymbol)), + contentDescription = null, + tint = Color.Unspecified, + 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), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_minus), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier + .size(16.dp) + .clickable(enabled = amount > step, onClick = onDecrease), + ) + Text( + text = "${amount.numberFormat8()} $symbol", + fontSize = 15.sp, + lineHeight = 22.sp, + color = MixinAppTheme.colors.textPrimary, + fontWeight = FontWeight.W500, + ) + Icon( + painter = painterResource(id = R.drawable.ic_perps_add), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier + .size(16.dp) + .clickable(onClick = onIncrease), + ) + } +} + +@Composable +private fun OrderPriceStepper( + price: BigDecimal, + symbol: String, + onDecrease: () -> Unit, + onIncrease: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_perps_minus), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier + .size(16.dp) + .clickable(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, + ) + Icon( + painter = painterResource(id = R.drawable.ic_perps_add), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier + .size(16.dp) + .clickable(onClick = onIncrease), + ) + } +} + +@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( + modifier = Modifier.fillMaxWidth(), + 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, + ) + Spacer(modifier = Modifier.weight(1f)) + 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(16.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 = 14.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 = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally) + ) { + GuideNavigationButton( + text = tabs[previousTab], + isPrevious = true, + modifier = Modifier.weight(1f), + onClick = { onSelect(previousTab) }, + ) + GuideNavigationButton( + text = stringResource(R.string.Start), + isPrevious = false, + modifier = Modifier.weight(1f), + onClick = onClose, + ) + } + return + } + if (previousTab != null && nextTab != null) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally) + ) { + GuideNavigationButton( + text = tabs[previousTab], + isPrevious = true, + modifier = Modifier.weight(1f), + onClick = { onSelect(previousTab) }, + ) + GuideNavigationButton( + text = tabs[nextTab], + isPrevious = false, + modifier = Modifier.weight(1f), + onClick = { onSelect(nextTab) }, + ) + } + return + } + val targetIndex = previousTab ?: nextTab ?: return + val buttonText = tabs[targetIndex] + val isPrevious = previousTab != null + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + GuideNavigationButton( + text = buttonText, + isPrevious = isPrevious, + modifier = Modifier.fillMaxWidth(0.5f), + onClick = { onSelect(targetIndex) }, + ) + } +} + +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 defaultPayAmount( + limitStrategy: LimitStrategy?, + strategy: LimitStrategy, +): BigDecimal { + if (limitStrategy == null) { + return BigDecimal("1000") + } + return when (strategy) { + LimitStrategy.BuyLow -> BigDecimal("1000") + LimitStrategy.SellHigh -> BigDecimal.ONE + } +} + +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 guideTokenIconRes( + token: TokenItem?, + fallbackSymbol: String, +): Int { + return when ((token?.symbol ?: fallbackSymbol).uppercase()) { + "USDT" -> R.drawable.ic_token_usdt + "BTC" -> R.drawable.ic_chain_btc + else -> R.drawable.ic_avatar_place_holder + } +} + +private fun safeDivide( + dividend: BigDecimal, + divisor: BigDecimal, +): BigDecimal { + if (divisor.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO + } + return dividend.divide(divisor, 8, RoundingMode.HALF_UP).stripTrailingZeros() +} diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradeFragment.kt index 17091c54ee..df2e98fd52 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 @@ -112,6 +112,7 @@ class TradeFragment : BaseFragment() { const val maxLeftAmount = 0.01 const val PREF_TRADE_SELECTED_TAB_PREFIX: String = "pref_trade_selected_tab_" + const val PREF_TRADE_SPOT_GUIDE_SHOWN: String = "pref_trade_spot_guide_shown" inline fun newInstance( input: String? = null, @@ -274,6 +275,10 @@ class TradeFragment : BaseFragment() { var isPerpetualOrderBadgeDismissed by remember(currentWalletId) { mutableStateOf(defaultSharedPreferences.getBoolean(perpetualOrderBadgePrefKey, false)) } + var hasShownSpotGuide by remember { + mutableStateOf(defaultSharedPreferences.getBoolean(PREF_TRADE_SPOT_GUIDE_SHOWN, false)) + } + val hasShownPerpetualGuide = isPerpetualTabBadgeDismissed TradePage( walletId = walletId, @@ -369,10 +374,58 @@ class TradeFragment : BaseFragment() { this@apply.hideKeyboard() navTo(OrderDetailFragment.newInstance(orderId), OrderDetailFragment.TAG) }, - onShowTradingGuide = { + hasShownSpotGuide = hasShownSpotGuide, + hasShownPerpetualGuide = hasShownPerpetualGuide, + onShowTradingGuideIfNeeded = { tabIndex -> this@apply.hideKeyboard() - PerpetualGuideBottomSheetDialogFragment.newInstance() - .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + when { + walletId == null && tabIndex >= SpotTradeGuideBottomSheetDialogFragment.TAB_LIMIT -> { + if (!hasShownPerpetualGuide) { + isPerpetualTabBadgeDismissed = true + defaultSharedPreferences.putBoolean(perpetualBadgePrefKey, true) + PerpetualGuideBottomSheetDialogFragment.newInstance() + .show(parentFragmentManager, PerpetualGuideBottomSheetDialogFragment.TAG) + } + } + tabIndex == 1 || tabIndex == 0 -> { + if (!hasShownSpotGuide) { + hasShownSpotGuide = true + defaultSharedPreferences.putBoolean(PREF_TRADE_SPOT_GUIDE_SHOWN, true) + val initialGuideTab = if (tabIndex == 1) { + SpotTradeGuideBottomSheetDialogFragment.TAB_LIMIT + } else { + SpotTradeGuideBottomSheetDialogFragment.TAB_SWAP + } + SpotTradeGuideBottomSheetDialogFragment.newInstance(initialGuideTab) + .show(parentFragmentManager, SpotTradeGuideBottomSheetDialogFragment.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) + } + } }, pop = { navigateUp(navController) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt index 7bc49ba2c6..fafaaa8278 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradePage.kt @@ -98,7 +98,10 @@ fun TradePage( onSwitchToLimitOrder: (String, SwapToken, SwapToken) -> Unit, pop: () -> Unit, onLimitOrderClick: (String) -> Unit, - onShowTradingGuide: () -> Unit, + hasShownSpotGuide: Boolean, + hasShownPerpetualGuide: Boolean, + onShowTradingGuideIfNeeded: (Int) -> Unit, + onShowTradingGuide: (Int) -> Unit, onShowMarketList: (Boolean) -> Unit, onShowAllMarkets: () -> Unit, onShowAllOpenPositions: () -> Unit, @@ -195,7 +198,7 @@ fun TradePage( perpetualTabIndex = tabs.size tabs += TabItem(title = stringResource(R.string.Perpetual)) { PerpetualContent( - onShowTradingGuide = onShowTradingGuide, + onShowTradingGuide = { onShowTradingGuide(perpetualTabIndex ?: 0) }, onShowMarketList = onShowMarketList, onShowAllMarkets = onShowAllMarkets, onShowAllOpenPositions = onShowAllOpenPositions, @@ -214,6 +217,21 @@ fun TradePage( pageCount = { tabCount }, ) + LaunchedEffect( + pagerState.currentPage, + perpetualTabIndex, + hasShownSpotGuide, + hasShownPerpetualGuide, + ) { + val currentPage = pagerState.currentPage + val isSpotGuideTab = currentPage == 0 || currentPage == 1 + val isPerpetualGuideTab = perpetualTabIndex != null && currentPage == perpetualTabIndex + when { + isSpotGuideTab && !hasShownSpotGuide -> onShowTradingGuideIfNeeded(currentPage) + isPerpetualGuideTab && !hasShownPerpetualGuide -> onShowTradingGuideIfNeeded(currentPage) + } + } + // When SwapContent requests switching to Limit tab, animate to it LaunchedEffect(switchToLimitRequested.value) { if (switchToLimitRequested.value) { @@ -231,8 +249,13 @@ fun TradePage( sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), sheetBackgroundColor = MixinAppTheme.colors.background, sheetContent = { + val currentGuideTitle = if (perpetualTabIndex != null && pagerState.currentPage == perpetualTabIndex) { + stringResource(R.string.Perpetual_Futures_Guide) + } else { + stringResource(R.string.Spot_Trading_Guide) + } HelpBottomSheetContent( - hideGuide = perpetualTabIndex == null || pagerState.currentPage != perpetualTabIndex, + guideTitle = currentGuideTitle, onContactSupport = { coroutineScope.launch { bottomSheetState.hide() @@ -242,7 +265,7 @@ fun TradePage( onTradingGuide = { coroutineScope.launch { bottomSheetState.hide() - onShowTradingGuide() + onShowTradingGuide(pagerState.currentPage) } }, onDismiss = { @@ -396,7 +419,6 @@ fun TradePage( } if (isPerpetualTab && !isPerpetualTabBadgeDismissed) { onDismissPerpetualTabBadge() - onShowTradingGuide() } onTabChanged(index) }, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt index 0c13dde382..521bdf5595 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualGuidePage.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -476,20 +477,19 @@ private fun GuideBottomNavigation( if (previousTab != null && nextTab == null) { Row( modifier = Modifier - .padding(horizontal = 20.dp) + .padding(horizontal = 18.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { GuideNavigationButton( - text = stringResource(R.string.Perpetual_Guide_Previous_Tab, tabs[previousTab]), + text = tabs[previousTab], + isPrevious = true, modifier = Modifier.weight(1f), onClick = { onSelect(previousTab) }, ) GuideNavigationButton( - text = stringResource( - R.string.Perpetual_Guide_Next_Tab, - stringResource(R.string.Start) - ), + text = stringResource(R.string.Start), + isPrevious = false, modifier = Modifier.weight(1f), onClick = onClose, ) @@ -499,17 +499,19 @@ private fun GuideBottomNavigation( if (previousTab != null && nextTab != null) { Row( modifier = Modifier - .padding(horizontal = 20.dp) + .padding(horizontal = 18.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { GuideNavigationButton( - text = stringResource(R.string.Perpetual_Guide_Previous_Tab, tabs[previousTab]), + text = tabs[previousTab], + isPrevious = true, modifier = Modifier.weight(1f), onClick = { onSelect(previousTab) }, ) GuideNavigationButton( - text = stringResource(R.string.Perpetual_Guide_Next_Tab, tabs[nextTab]), + text = tabs[nextTab], + isPrevious = false, modifier = Modifier.weight(1f), onClick = { onSelect(nextTab) }, ) @@ -517,17 +519,15 @@ private fun GuideBottomNavigation( 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]) - } + val buttonText = tabs[targetIndex] + val isPrevious = previousTab != null Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { GuideNavigationButton( text = buttonText, + isPrevious = isPrevious, modifier = Modifier.fillMaxWidth(0.5f), onClick = { onSelect(targetIndex) }, ) @@ -535,22 +535,44 @@ private fun GuideBottomNavigation( } @Composable -private fun GuideNavigationButton( +fun GuideNavigationButton( text: String, + isPrevious: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit, ) { MixinButton( - modifier = modifier.height(48.dp), + modifier = modifier.wrapContentSize(), onClick = onClick, shape = RoundedCornerShape(32.dp), ) { - Text( - text = text, - fontSize = 14.sp, - fontWeight = FontWeight.W500, - color = Color.White, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (isPrevious) { + Icon( + painter = painterResource(id = R.drawable.ic_guide_previous), + contentDescription = null, + tint = Color.Unspecified, + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = text, + fontSize = 14.sp, + fontWeight = FontWeight.W500, + color = Color.White, + ) + if (!isPrevious) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_guide_next), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } } } diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index d7e474553b..caad259f44 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt @@ -42,6 +42,8 @@ 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.PlatformTextStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity @@ -622,6 +624,7 @@ private fun OpenPositionCard( val entryPrice = position.entryPrice.toBigDecimalOrNull() ?: BigDecimal.ZERO val liquidationPrice = calculateLiquidationPriceValue(entryPrice, position.leverage, isLong) + val compactTextStyle = TextStyle(platformStyle = PlatformTextStyle(includeFontPadding = false)) Column( modifier = Modifier @@ -638,6 +641,8 @@ private fun OpenPositionCard( Text( text = stringResource(R.string.perps_position), fontSize = 16.sp, + lineHeight = 16.sp, + style = compactTextStyle, fontWeight = FontWeight.Medium, color = MixinAppTheme.colors.textPrimary ) @@ -652,33 +657,42 @@ private fun OpenPositionCard( ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(20.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( text = stringResource(R.string.PnL).uppercase(), fontSize = 12.sp, + lineHeight = 14.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = "${formatPerpsSignedFiatDecimal(pnl.multiply(fiatRate), fiatSymbol)}(${formatPerpsSignedPercent(roe)})", - fontSize = 14.sp, - color = pnlColor - ) - } - Column(horizontalAlignment = Alignment.End) { Text( text = stringResource(R.string.Direction).uppercase(), fontSize = 12.sp, + lineHeight = 14.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.height(4.dp)) + } + Spacer(modifier = Modifier.height(7.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${formatPerpsSignedFiatDecimal(pnl.multiply(fiatRate), fiatSymbol)}(${formatPerpsSignedPercent(roe)})", + fontSize = 14.sp, + lineHeight = 17.sp, + style = compactTextStyle, + color = pnlColor + ) Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier @@ -690,6 +704,7 @@ private fun OpenPositionCard( text = if (isLong) stringResource(R.string.Long) else stringResource(R.string.Short), fontSize = 10.sp, lineHeight = 12.sp, + style = compactTextStyle, color = Color.White ) } @@ -697,26 +712,31 @@ private fun OpenPositionCard( Text( text = "${position.leverage}x", fontSize = 14.sp, + lineHeight = 17.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textPrimary ) } } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(20.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = stringResource(R.string.position_size).uppercase(), fontSize = 12.sp, + lineHeight = 14.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(9.dp)) Icon( painter = painterResource(id = R.drawable.ic_tip), contentDescription = null, @@ -731,61 +751,78 @@ private fun OpenPositionCard( tint = MixinAppTheme.colors.textAssist ) } + Text( + text = stringResource(R.string.Margin).uppercase(), + fontSize = 12.sp, + lineHeight = 14.sp, + style = compactTextStyle, + color = MixinAppTheme.colors.textAssist + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( text = "${quantity.stripTrailingZeros().toPlainString()} ${position.tokenSymbol}", fontSize = 14.sp, + lineHeight = 17.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textPrimary ) - } - - Column(horizontalAlignment = Alignment.End) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.Margin).uppercase(), - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - - } Text( text = formatPerpsFiatDecimal(amountValue, fiatSymbol), fontSize = 14.sp, + lineHeight = 17.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textPrimary ) } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(20.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( text = stringResource(R.string.Entry_Price).uppercase(), fontSize = 12.sp, + lineHeight = 14.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textAssist ) - Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.Liquidation_Price).uppercase(), + fontSize = 12.sp, + lineHeight = 14.sp, + style = compactTextStyle, + color = MixinAppTheme.colors.textAssist + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { Text( text = "${fiatSymbol}${entryPrice.multiply(fiatRate).priceFormat()}", fontSize = 14.sp, + lineHeight = 17.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textPrimary ) - } - - Column(horizontalAlignment = Alignment.End) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.Liquidation_Price).uppercase(), - fontSize = 12.sp, - color = MixinAppTheme.colors.textAssist - ) - } Text( text = "${fiatSymbol}${liquidationPrice.multiply(fiatRate).priceFormat()}", fontSize = 14.sp, + lineHeight = 17.sp, + style = compactTextStyle, color = MixinAppTheme.colors.textPrimary ) } diff --git a/app/src/main/java/one/mixin/android/ui/setting/member/MixinMemberUpgradeBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/member/MixinMemberUpgradeBottomSheetDialogFragment.kt index 2af5919948..be909cda24 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/member/MixinMemberUpgradeBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/member/MixinMemberUpgradeBottomSheetDialogFragment.kt @@ -27,7 +27,6 @@ import one.mixin.android.job.RefreshAccountJob 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.conversation.link.parser.NewSchemeParser import one.mixin.android.ui.setting.ui.page.MixinMemberUpgradePage import one.mixin.android.ui.viewmodel.MemberViewModel import one.mixin.android.ui.web.WebActivity 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 67275313bf..c2e49603a3 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 @@ -684,7 +684,7 @@ class WebFragment : BaseFragment() { icon: Bitmap?, ) { super.onReceivedIcon(view, icon) - if (!isBot()) { + if (!isBot() && fixedTitle == null) { icon?.let { _binding?.apply { iconIv.isVisible = true @@ -911,8 +911,12 @@ class WebFragment : BaseFragment() { } } app?.name?.let { binding.titleTv.text = it } - fixedTitle?.let { binding.titleTv.text = it } - app?.iconUrl?.let { + fixedTitle?.let { + binding.titleTv.text = it + binding.iconIv.isVisible = false + binding.webControl.hideMore() + } + if (fixedTitle == null) app?.iconUrl?.let { binding.iconIv.isVisible = true binding.iconIv.loadImage(it) binding.titleTv.updateLayoutParams { diff --git a/app/src/main/java/one/mixin/android/widget/WebControlView.kt b/app/src/main/java/one/mixin/android/widget/WebControlView.kt index d2ce72136b..3388c2e2c0 100644 --- a/app/src/main/java/one/mixin/android/widget/WebControlView.kt +++ b/app/src/main/java/one/mixin/android/widget/WebControlView.kt @@ -10,6 +10,8 @@ import one.mixin.android.databinding.ViewWebControlBinding import one.mixin.android.extension.dp class WebControlView(context: Context, attrs: AttributeSet) : LinearLayout(context, attrs) { + private var isMoreHidden = false + var mode = false set(value) { if (value != field) { @@ -33,20 +35,27 @@ class WebControlView(context: Context, attrs: AttributeSet) : LinearLayout(conte if (dark) { setBackgroundResource(R.drawable.bg_view_web_control_black) binding.moreIv.setImageResource(R.drawable.ic_more_horiz_white_24dp) - binding.closeIv.setImageResource(R.drawable.ic_close_white_24dp) + if (!isMoreHidden) { + binding.closeIv.setImageResource(R.drawable.ic_close_white_24dp) + } binding.divide.setBackgroundColor(context.getColor(R.color.bgWhiteNight)) } else { setBackgroundResource(R.drawable.bg_view_web_control_white) binding.moreIv.setImageResource(R.drawable.ic_more_horiz_black_24dp) - binding.closeIv.setImageResource(R.drawable.ic_close_dark_24dp) + if (!isMoreHidden) { + binding.closeIv.setImageResource(R.drawable.ic_close_dark_24dp) + } binding.divide.setBackgroundColor(context.getColor(R.color.bgWhite)) } } fun hideMore() { + isMoreHidden = true binding.divide.isVisible = false binding.moreFl.isVisible = false layoutParams = layoutParams.apply { width = 36.dp } + background = null + binding.closeIv.setImageResource(R.drawable.ic_circle_close) weightSum = 1f } 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/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_guide_next.xml b/app/src/main/res/drawable/ic_guide_next.xml new file mode 100644 index 0000000000..efe9635c1c --- /dev/null +++ b/app/src/main/res/drawable/ic_guide_next.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_guide_previous.xml b/app/src/main/res/drawable/ic_guide_previous.xml new file mode 100644 index 0000000000..d5d8408379 --- /dev/null +++ b/app/src/main/res/drawable/ic_guide_previous.xml @@ -0,0 +1,13 @@ + + + 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_token_usdt.xml b/app/src/main/res/drawable/ic_token_usdt.xml new file mode 100644 index 0000000000..9b81d381b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_token_usdt.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 048d21e837..e900f51408 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2234,9 +2234,8 @@ Safe 金库、观察钱包和隐藏的资产不计入统计 共管的 Safe 金库不计入统计 交易说明 + 现货交易指南 简介 - < %1$s - %1$s > 永续合约允许您使用杠杆交易加密货币,从而放大您的潜在利润(和损失)。您可以做多(押注价格上涨)或做空(押注价格下跌),而无需拥有标的资产。 您可以随时平仓以实现盈亏。平仓价格基于当前市场价格。请务必监控您的仓位以避免爆仓。 具体说明 @@ -2337,5 +2336,41 @@ https://support.mixin.one/zh/article/pin-1vb2rc7/ 切换账号 永续合约指南 + Mixin 交易支持简单闪兑和专业模式,支持多链、多币种交易,并聚合多个 DEX 与 CEX 的流动性。 + 最优聚合:接入 Uniswap、 1inch、Jupiter、MixPay、BigONE 等 DEX 和 CEX。 + 跨链交易:支持 Bitcoin、Ethereum、Solana 等多链资产交易。 + 补充说明 + 费用说明 + 交易手续费主要由交易所和网络提现手续费构成,Mixin 不额外收取交易手续费 + 举例说明 + 交易对 + 支付金额 + 兑换价格 + 交易策略 + 成交价格 + 市场价格 + 适合场景 + 报价说明 + 闪兑是一种按当前最优市场价格立即成交的兑换方式。 + 需要快速成交 + 交易主流或流动性充足的交易对 + 适合简单兑换需求 + 价格来自 DEX 与 CEX 的聚合最优报价 + 报价已包含交易所手续费和网络手续费 + 市场波动较大时,实际成交价格可能与报价存在差异 + 低价买入 + 高价卖出 + 专业模式支持限价挂单,用户可自行设定成交价格,达到预期价格时才会成交。 + 有明确交易策略,如低买高卖 + 对成交价格敏感 + 适合大额交易 + 市场波动较大时,价格可能快速越过挂单价格,订单可能无法成交 + 钱包之间免费提现 + 直接使用 Mixin 转账给朋友,无需手续费。 + 导入到 Mixin 安全吗? + Mixin 是开源的、可复现构建的,并经过独立审计。 + 您的数据会被安全加密,并仅存储在您的本地设备上。 + Mixin 绝不会访问您的隐私信息,也不会与第三方共享。 + 预计获得 正在处理 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d56de3f37a..45f2ab760f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2296,9 +2296,8 @@ Safes, watch wallets, and hidden assets are excluded from the total Co-managed safes are excluded from the total Trading Guide + Spot 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. Leverage allows you to control a larger position with less capital. 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. @@ -2407,5 +2406,41 @@ https://support.mixin.one/en/article/what-should-i-do-if-i-forget-my-pin-g12pmo/ Switch Account Perpetual Futures Guide + Mixin supports both Simple and Advanced trading modes, allowing you to trade across multiple chains and assets with aggregated liquidity from DEXs and CEXs. + Liquidity aggregation: access to liquidity from Uniswap, 1inch, Jupiter, MixPay, BigONE, and other DEXs and CEXs. + Cross-chain trading: supports assets across Bitcoin, Ethereum, Solana, and more. + Additional Notes + Fees + Trading fees mainly consist of exchange fees and network fees. Mixin does not charge additional platform fees. + Example + Trading Pair + You Pay + Exchange Price + Strategy + Limit Price + Market Price + Use Cases + Pricing + Simple mode enables instant swaps at the best available market price. + When you need fast execution + For major pairs or high-liquidity markets + For quick and easy swaps + Prices are aggregated from DEXs and CEXs to provide the best available quote + Quotes include exchange and network fees + Actual execution price may differ from the quoted price during high volatility. + Buy Low + Sell High + Advanced supports limit orders, allowing you to set your preferred execution price. Orders are executed only when the market reaches your specified price. + For strategic trading + For precise pricing + For large trades + The market price may move past your limit price, and your order may not be filled. + Free transfers between wallets + Send crypto to friends using Mixin directly without fees. + 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. + You Receive Opening