diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f2e4fa391a..bb5414bd31 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,6 +25,7 @@ val kotlinVersion = rootProject.extra["kotlinVersion"] as String val tinkVersion = rootProject.extra["tinkVersion"] as String val securityCryptoVersion = rootProject.extra["securityCryptoVersion"] as String val webkitVersion = rootProject.extra["webkitVersion"] as String +val lightweightChartsVersion = rootProject.extra["lightweightChartsVersion"] as String val mixinJwtVersion = rootProject.extra["mixinJwtVersion"] as String val alphabetIndexVersion = rootProject.extra["alphabetIndexVersion"] as String val web3jVersion = rootProject.extra["web3jVersion"] as String @@ -358,6 +359,7 @@ dependencies { implementation("androidx.biometric:biometric:$biometricVersion") implementation("androidx.security:security-crypto:$securityCryptoVersion") implementation("androidx.webkit:webkit:$webkitVersion") + implementation("com.tradingview:lightweightcharts:$lightweightChartsVersion") implementation("org.sol4k:tweetnacl:$tweetnaclVersion") implementation("org.sol4k:utilities:$sol4kUtilitiesVersion") diff --git a/app/src/main/assets/trading_view_local_time_tick_mark_formatter.js b/app/src/main/assets/trading_view_local_time_tick_mark_formatter.js new file mode 100644 index 0000000000..5ed94e8a28 --- /dev/null +++ b/app/src/main/assets/trading_view_local_time_tick_mark_formatter.js @@ -0,0 +1,32 @@ +window.localTimeTickMarkFormatter = () => { + return (time, tickMarkType, locale) => { + if (typeof time !== "number") { + return null + } + + const formatOptions = {} + switch (tickMarkType) { + case 0: + formatOptions.year = "numeric" + break + case 1: + formatOptions.month = "short" + break + case 2: + formatOptions.day = "numeric" + break + case 3: + formatOptions.hour12 = false + formatOptions.hour = "2-digit" + formatOptions.minute = "2-digit" + break + case 4: + formatOptions.hour12 = false + formatOptions.hour = "2-digit" + formatOptions.minute = "2-digit" + formatOptions.second = "2-digit" + break + } + return new Date(time * 1000).toLocaleString(locale, formatOptions) + } +} diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index c705fc4451..dae5284f61 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -131,6 +131,7 @@ object Constants { const val PREF_PERPS_ACCEPTED_ASSET_IDS_ORDERED = "pref_perps_accepted_asset_ids_ordered" const val PREF_PERPS_ACCEPTED_ASSET_IDS_V2 = "pref_perps_accepted_asset_ids_v2" const val PREF_LAST_SELECTED_PERPS_ASSET_ID = "pref_last_selected_perps_asset_id" + const val PREF_USE_TRADING_VIEW_CANDLES = "pref_use_trading_view_candles" const val PREF_USED_WALLET = "pref_used_wallet" 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 f909bff625..61a7e5e55a 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/CandleChart.kt @@ -1,5 +1,6 @@ package one.mixin.android.ui.home.web3.trade +import android.content.SharedPreferences import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -23,6 +24,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -130,15 +132,33 @@ private fun PointerEvent.compatCalculateZoom(): Float { fun CandleChart( marketId: String, timeFrame: String, + priceScale: Int, marketPrice: String? = null, ) { val context = LocalContext.current + val preferences = context.defaultSharedPreferences val dataError = stringResource(R.string.Data_error) val viewModel = hiltViewModel() val lifecycleOwner = LocalLifecycleOwner.current var candles by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } var errorMessage by remember { mutableStateOf(null) } + var useTradingView by remember { + mutableStateOf(preferences.getBoolean(Constants.Account.PREF_USE_TRADING_VIEW_CANDLES, false)) + } + + DisposableEffect(preferences) { + val listener = + SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> + if (key == Constants.Account.PREF_USE_TRADING_VIEW_CANDLES) { + useTradingView = sharedPreferences.getBoolean(key, false) + } + } + preferences.registerOnSharedPreferenceChangeListener(listener) + onDispose { + preferences.unregisterOnSharedPreferenceChangeListener(listener) + } + } LaunchedEffect(marketId, timeFrame, lifecycleOwner) { candles = emptyList() @@ -194,12 +214,21 @@ fun CandleChart( ) } else -> { - ScrollableCandleChart( - candles = candles, - context = context, - marketPrice = marketPrice?.toBigDecimalOrNull(), - marketPriceText = marketPrice, - ) + if (useTradingView) { + TradingViewCandleChart( + candles = candles, + marketPrice = marketPrice, + priceScale = priceScale, + timeFrame = timeFrame, + ) + } else { + ScrollableCandleChart( + candles = candles, + context = context, + marketPrice = marketPrice?.toBigDecimalOrNull(), + marketPriceText = marketPrice, + ) + } } } } 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 6dbcbf9d7a..e41fa8f9a4 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 @@ -86,6 +86,7 @@ import one.mixin.android.ui.common.BaseFragment import one.mixin.android.ui.home.web3.Web3ViewModel import one.mixin.android.ui.home.web3.trade.perps.AllPositionsFragment import one.mixin.android.ui.home.web3.trade.perps.PerpetualGuideBottomSheetDialogFragment +import one.mixin.android.ui.home.web3.trade.perps.PerpetualViewModel import one.mixin.android.ui.home.web3.trade.perps.PerpsActivity import one.mixin.android.ui.home.web3.trade.perps.PerpsMarketListBottomSheetDialogFragment import one.mixin.android.ui.home.web3.trade.perps.PositionDetailFragment @@ -224,6 +225,7 @@ class TradeFragment : BaseFragment() { private val swapViewModel by viewModels() private val web3ViewModel by viewModels() + private val perpetualViewModel by viewModels() private val coroutineErrorHandler = CoroutineExceptionHandler { _, error -> Timber.e(error) ErrorHandler.handleError(error) @@ -593,14 +595,18 @@ class TradeFragment : BaseFragment() { ) }, onOpenPositionClick = { position -> - requireActivity().supportFragmentManager.navigateToPerpsRoute( - PositionDetailFragment.newInstance( - position, + lifecycleScope.launch { + val market = perpetualViewModel.getMarketFromDb(position.marketId) + val activity = activity ?: return@launch + PerpsActivity.showDetail( + activity, + position.marketId, + market?.displaySymbol ?: position.displaySymbol.orEmpty(), + market?.displaySymbol ?: position.displaySymbol.orEmpty(), + market?.tokenSymbol ?: position.tokenSymbol.orEmpty(), AnalyticsTracker.PerpsSource.PERPS_HOME_LIST, - ), - PositionDetailFragment.TAG, - R.id.container, - ) + ) + } }, onMarketItemClick = { market -> PerpsActivity.showDetail( diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChart.kt new file mode 100644 index 0000000000..9c5eb8b3e9 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChart.kt @@ -0,0 +1,627 @@ +package one.mixin.android.ui.home.web3.trade + +import android.content.Context +import android.os.Build +import android.view.GestureDetector +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.view.ViewGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.tradingview.lightweightcharts.api.chart.models.color.surface.SolidColor +import com.tradingview.lightweightcharts.api.chart.models.color.toIntColor +import com.tradingview.lightweightcharts.api.interfaces.ChartApi +import com.tradingview.lightweightcharts.api.interfaces.SeriesApi +import com.tradingview.lightweightcharts.api.options.models.CandlestickSeriesOptions +import com.tradingview.lightweightcharts.api.options.models.PriceLineOptions +import com.tradingview.lightweightcharts.api.options.models.crosshairLineOptions +import com.tradingview.lightweightcharts.api.options.models.crosshairOptions +import com.tradingview.lightweightcharts.api.options.models.gridLineOptions +import com.tradingview.lightweightcharts.api.options.models.gridOptions +import com.tradingview.lightweightcharts.api.options.models.handleScaleOptions +import com.tradingview.lightweightcharts.api.options.models.handleScrollOptions +import com.tradingview.lightweightcharts.api.options.models.layoutOptions +import com.tradingview.lightweightcharts.api.options.models.localizationOptions +import com.tradingview.lightweightcharts.api.options.models.priceScaleOptions +import com.tradingview.lightweightcharts.api.options.models.timeScaleOptions +import com.tradingview.lightweightcharts.api.series.common.PriceLine +import com.tradingview.lightweightcharts.api.series.enums.LineStyle +import com.tradingview.lightweightcharts.api.series.models.CandlestickData +import com.tradingview.lightweightcharts.api.series.models.MouseEventParams +import com.tradingview.lightweightcharts.api.series.models.PriceFormat +import com.tradingview.lightweightcharts.api.series.models.Time +import com.tradingview.lightweightcharts.runtime.plugins.DateTimeFormat +import com.tradingview.lightweightcharts.runtime.plugins.Plugin +import com.tradingview.lightweightcharts.runtime.plugins.TimeFormatter +import com.tradingview.lightweightcharts.view.ChartsView +import com.tradingview.lightweightcharts.view.gesture.TouchDelegate +import one.mixin.android.Constants +import one.mixin.android.R +import one.mixin.android.api.response.perps.CandleItem +import one.mixin.android.api.response.perps.CandleView +import one.mixin.android.compose.theme.MixinAppTheme +import one.mixin.android.extension.defaultSharedPreferences +import java.math.BigDecimal +import java.math.RoundingMode +import java.util.Locale +import kotlin.math.abs +import org.threeten.bp.Instant +import org.threeten.bp.ZoneId +import org.threeten.bp.format.DateTimeFormatter + +@Composable +fun TradingViewCandleChart( + candles: List, + marketPrice: String?, + priceScale: Int, + timeFrame: String, +) { + val context = LocalContext.current + val quoteColorReversed = + context.defaultSharedPreferences.getBoolean(Constants.Account.PREF_QUOTE_COLOR, false) + val upColor = if (quoteColorReversed) MixinAppTheme.colors.walletRed else MixinAppTheme.colors.walletGreen + val downColor = if (quoteColorReversed) MixinAppTheme.colors.walletGreen else MixinAppTheme.colors.walletRed + val backgroundColor = MixinAppTheme.colors.background + val accentColor = MixinAppTheme.colors.accent + val textColor = MixinAppTheme.colors.textAssist + val gridColor = MixinAppTheme.colors.borderColor + val crosshairColor = MixinAppTheme.colors.textAssist + val dateTimeFormat = remember(timeFrame) { tradingViewDateTimeFormat(timeFrame) } + val locale = remember { Locale.getDefault().toLanguageTag() } + val chartTimeFormatter = + remember(locale, dateTimeFormat) { + TimeFormatter( + locale = locale, + dateTimeFormat = + if (dateTimeFormat == DateTimeFormat.TIME) { + DateTimeFormat.DATE_TIME + } else { + DateTimeFormat.DATE + }, + ) + } + val tickMarkFormatter = remember { tradingViewLocalTimeTickMarkFormatter() } + val dataErrorMessage = stringResource(R.string.Data_error) + val chartPriceFormat = + remember(priceScale) { + val precision = priceScale.coerceAtLeast(0) + PriceFormat.priceFormatBuiltIn( + type = PriceFormat.Type.PRICE, + precision = precision, + minMove = BigDecimal.ONE.movePointLeft(precision).toFloat(), + ) + } + val chartCandles = + remember(candles) { + tradingViewCandles(candles.firstOrNull()?.items.orEmpty()) + } + val candleData = chartCandles.map { it.data } + val candlesByTimestamp = + remember(chartCandles) { + chartCandles.associate { candle -> + (candle.data.time as Time.Utc).timestamp to candle.item + } + } + val latestCandlesByTimestamp = rememberUpdatedState(candlesByTimestamp) + val currentPrice = marketPrice?.toFloatOrNull() + val latestOpen = candleData.lastOrNull()?.open + val priceLineColor = if (currentPrice != null && latestOpen != null && currentPrice < latestOpen) downColor else upColor + var chartApi by remember { mutableStateOf(null) } + var seriesApi by remember { mutableStateOf(null) } + var marketPriceLine by remember { mutableStateOf(null) } + var hasInitialPosition by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var selectedCandle by remember { mutableStateOf(null) } + var isLongPressActive by remember { mutableStateOf(false) } + val onCrosshairMove = + remember { + { params: MouseEventParams -> + if (isLongPressActive) { + val candle = candleForTradingViewTime(params.time, latestCandlesByTimestamp.value) + selectedCandle = + if (candle == null) { + null + } else { + SelectedCandle(candle, params.point?.x ?: 0f) + } + } + } + } + + LaunchedEffect(seriesApi, candleData) { + val series = seriesApi ?: return@LaunchedEffect + series.setData(candleData) + if (!hasInitialPosition && candleData.isNotEmpty()) { + chartApi?.timeScale?.scrollToPosition(INITIAL_RIGHT_OFFSET, false) + hasInitialPosition = true + } + } + + LaunchedEffect(chartApi, priceLineColor, accentColor, crosshairColor) { + chartApi?.applyOptions { + crosshair = crosshairOptions { + vertLine = crosshairLineOptions { + color = crosshairColor.toArgb().toIntColor() + labelBackgroundColor = accentColor.toArgb().toIntColor() + } + horzLine = crosshairLineOptions { + color = crosshairColor.toArgb().toIntColor() + labelBackgroundColor = priceLineColor.toArgb().toIntColor() + } + } + } + } + + DisposableEffect(chartApi, onCrosshairMove) { + val api = chartApi + if (api == null) { + onDispose { } + } else { + api.subscribeCrosshairMove(onCrosshairMove) + onDispose { + api.unsubscribeCrosshairMove(onCrosshairMove) + } + } + } + + LaunchedEffect(seriesApi, marketPrice, priceLineColor) { + val series = seriesApi ?: return@LaunchedEffect + val price = marketPrice?.toFloatOrNull() ?: return@LaunchedEffect + val options = + PriceLineOptions( + price = price, + color = priceLineColor.toArgb().toIntColor(), + lineStyle = LineStyle.DASHED, + lineVisible = true, + axisLabelVisible = true, + ) + marketPriceLine?.applyOptions(options) ?: run { + marketPriceLine = series.createPriceLine(options) + } + } + + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { chartContext -> + ChartsView(chartContext).apply { + val selectCandleAt: (Float) -> Unit = { pointX -> + api.timeScale.coordinateToTime(pointX) { time -> + if (isLongPressActive) { + candleForTradingViewTime(time, latestCandlesByTimestamp.value)?.let { candle -> + selectedCandle = SelectedCandle(candle, pointX) + } + } + } + } + addTouchDelegate( + ChartTouchDelegate( + context = chartContext, + onTouchStart = { + isLongPressActive = false + selectedCandle = null + }, + onLongPress = { pointX -> + isLongPressActive = true + selectCandleAt(pointX) + }, + onLongPressMove = selectCandleAt, + onTouchEnd = { + isLongPressActive = false + selectedCandle = null + }, + ), + ) + subscribeOnChartStateChange { state -> + errorMessage = + tradingViewErrorMessage( + (state as? ChartsView.State.Error)?.exception, + dataErrorMessage, + ) + } + api.applyOptions { + layout = layoutOptions { + background = SolidColor(backgroundColor.toArgb()) + this.textColor = textColor.toArgb().toIntColor() + } + localization = localizationOptions { + this.locale = locale + timeFormatter = chartTimeFormatter + } + timeScale = timeScaleOptions { + rightOffset = INITIAL_RIGHT_OFFSET + barSpacing = INITIAL_BAR_SPACING + minBarSpacing = MIN_BAR_SPACING + rightBarStaysOnScroll = true + timeVisible = dateTimeFormat == DateTimeFormat.TIME + secondsVisible = false + this.tickMarkFormatter = tickMarkFormatter + borderColor = gridColor.toArgb().toIntColor() + } + rightPriceScale = priceScaleOptions { + borderColor = gridColor.toArgb().toIntColor() + } + grid = gridOptions { + horzLines = gridLineOptions { + color = gridColor.toArgb().toIntColor() + } + vertLines = gridLineOptions { + color = gridColor.toArgb().toIntColor() + } + } + handleScroll = handleScrollOptions { + horzTouchDrag = true + vertTouchDrag = false + } + handleScale = handleScaleOptions { + pinch = true + } + } + chartApi = api + api.addCandlestickSeries( + options = + CandlestickSeriesOptions( + upColor = upColor.toArgb().toIntColor(), + downColor = downColor.toArgb().toIntColor(), + wickUpColor = upColor.toArgb().toIntColor(), + wickDownColor = downColor.toArgb().toIntColor(), + borderVisible = false, + lastValueVisible = false, + priceLineVisible = false, + priceFormat = chartPriceFormat, + ), + onSeriesCreated = { seriesApi = it }, + ) + } + }, + ) + selectedCandle?.let { selection -> + val alignment = + if (selection.pointX < maxWidth.value / 2f) { + Alignment.TopEnd + } else { + Alignment.TopStart + } + CandleDetailsTooltip( + candle = selection.candle, + timeFrame = timeFrame, + priceScale = priceScale, + upColor = upColor, + downColor = downColor, + modifier = + Modifier + .align(alignment) + .padding(8.dp), + ) + } + errorMessage?.let { error -> + Text( + text = error, + fontSize = 14.sp, + color = MixinAppTheme.colors.textAssist, + ) + } + } +} + +@Composable +private fun CandleDetailsTooltip( + candle: CandleItem, + timeFrame: String, + priceScale: Int, + upColor: Color, + downColor: Color, + modifier: Modifier = Modifier, +) { + val open = candle.open.toBigDecimalOrNull() + val close = candle.close.toBigDecimalOrNull() + val change = if (open != null && close != null) close - open else null + val changePercent = + if (change != null && open != null && open.compareTo(BigDecimal.ZERO) != 0) { + change + .divide(open, CHANGE_PERCENT_CALCULATION_SCALE, RoundingMode.HALF_UP) + .multiply(BigDecimal(100)) + } else { + null + } + val changeColor = if (change == null || change >= BigDecimal.ZERO) upColor else downColor + + Surface( + modifier = modifier.width(140.dp), + shape = RoundedCornerShape(4.dp), + color = MixinAppTheme.colors.background, + elevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + CandleDetailsRow( + label = stringResource(R.string.Candle_Time), + value = formatCandleTime(candle.timestamp, timeFrame), + ) + CandleDetailsRow( + label = stringResource(R.string.Candle_Open), + value = formatCandlePrice(candle.open, priceScale), + ) + CandleDetailsRow( + label = stringResource(R.string.Candle_High), + value = formatCandlePrice(candle.high, priceScale), + ) + CandleDetailsRow( + label = stringResource(R.string.Candle_Low), + value = formatCandlePrice(candle.low, priceScale), + ) + CandleDetailsRow( + label = stringResource(R.string.Candle_Close), + value = formatCandlePrice(candle.close, priceScale), + ) + CandleDetailsRow( + label = stringResource(R.string.Candle_Change), + value = formatSignedDecimal(change, priceScale), + valueColor = changeColor, + ) + CandleDetailsRow( + label = stringResource(R.string.Candle_Change_Percent), + value = + changePercent?.let { + "${formatSignedDecimal(it, CHANGE_PERCENT_DISPLAY_SCALE)}%" + } ?: "--", + valueColor = changeColor, + ) + } + } +} + +@Composable +private fun CandleDetailsRow( + label: String, + value: String, + valueColor: Color = MixinAppTheme.colors.textPrimary, +) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "$label:", + color = MixinAppTheme.colors.textAssist, + fontSize = 9.sp, + lineHeight = 12.sp, + maxLines = 1, + modifier = Modifier.width(40.dp), + ) + Text( + text = value, + color = valueColor, + fontSize = 9.sp, + lineHeight = 12.sp, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } +} + +private fun formatCandlePrice( + value: String, + priceScale: Int, +): String = + value.toBigDecimalOrNull() + ?.setScale(priceScale.coerceAtLeast(0), RoundingMode.HALF_UP) + ?.toPlainString() + ?: value + +private fun formatSignedDecimal( + value: BigDecimal?, + scale: Int, +): String { + if (value == null) return "--" + val normalized = value.setScale(scale.coerceAtLeast(0), RoundingMode.HALF_UP) + val sign = if (normalized > BigDecimal.ZERO) "+" else "" + return "$sign${normalized.toPlainString()}" +} + +private fun formatCandleTime( + timestamp: Long, + timeFrame: String, +): String { + val millis = if (timestamp < MILLIS_TIMESTAMP_THRESHOLD) timestamp * 1000 else timestamp + val zoneId = ZoneId.systemDefault() + val pattern = if (timeFrame.lowercase() in setOf("1d", "1w")) "yyyy-MM-dd" else "yyyy-MM-dd HH:mm" + return runCatching { + Instant.ofEpochMilli(millis) + .atZone(zoneId) + .format(DateTimeFormatter.ofPattern(pattern).withZone(zoneId)) + }.getOrDefault("--") +} + +private data class SelectedCandle( + val candle: CandleItem, + val pointX: Float, +) + +internal fun normalizeTradingViewTimestamp(timestamp: Long): Long = + if (timestamp >= MILLIS_TIMESTAMP_THRESHOLD) timestamp / 1000 else timestamp + +internal data class TradingViewCandle( + val item: CandleItem, + val data: CandlestickData, +) + +internal fun tradingViewCandles( + items: List, +): List = + items.mapNotNull { item -> + val open = item.open.toFloatOrNull() ?: return@mapNotNull null + val high = item.high.toFloatOrNull() ?: return@mapNotNull null + val low = item.low.toFloatOrNull() ?: return@mapNotNull null + val close = item.close.toFloatOrNull() ?: return@mapNotNull null + if (!open.isFinite() || !high.isFinite() || !low.isFinite() || !close.isFinite()) { + return@mapNotNull null + } + val timestamp = normalizeTradingViewTimestamp(item.timestamp) + TradingViewCandle( + item = item, + data = + CandlestickData( + time = Time.Utc(timestamp), + open = open, + high = high, + low = low, + close = close, + ), + ) + }.associateBy { candle -> + (candle.data.time as Time.Utc).timestamp + }.values.sortedBy { candle -> + (candle.data.time as Time.Utc).timestamp + } + +internal fun tradingViewDateTimeFormat(timeFrame: String): DateTimeFormat = + when (timeFrame.lowercase()) { + "1d", "1w" -> DateTimeFormat.DATE + else -> DateTimeFormat.TIME + } + +internal fun candleForTradingViewTime( + time: Time?, + candlesByTimestamp: Map, +): CandleItem? = (time as? Time.Utc)?.timestamp?.let(candlesByTimestamp::get) + +internal fun tradingViewErrorMessage( + error: Throwable?, + fallback: String, +): String? = error?.localizedMessage?.takeIf(String::isNotBlank) ?: error?.let { fallback } + +private fun tradingViewLocalTimeTickMarkFormatter() = + Plugin( + name = "localTimeTickMarkFormatter", + file = "/android_asset/trading_view_local_time_tick_mark_formatter.js", + ) + +private class ChartTouchDelegate( + context: Context, + private val onTouchStart: () -> Unit, + private val onLongPress: (Float) -> Unit, + private val onLongPressMove: (Float) -> Unit, + private val onTouchEnd: () -> Unit, +) : TouchDelegate { + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var downX = 0f + private var downY = 0f + private var lastLongPressX = 0f + private var longPressActive = false + private var targetView: ViewGroup? = null + private val gestureDetector = + GestureDetector( + context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent): Boolean = true + + override fun onLongPress(e: MotionEvent) { + longPressActive = true + lastLongPressX = e.x + targetView?.requestDisallowInterceptTouchEvent(true) + targetView?.performHapticFeedback( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.GESTURE_START + } else { + HapticFeedbackConstants.LONG_PRESS + }, + ) + onLongPress(e.x) + } + }, + ) + + override fun beforeTouchEvent(view: ViewGroup) { + targetView = view + } + + override fun onTouchEvent( + view: ViewGroup, + event: MotionEvent, + ): Boolean { + gestureDetector.onTouchEvent(event) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + onTouchStart() + downX = event.x + downY = event.y + longPressActive = false + view.requestDisallowInterceptTouchEvent(true) + } + + MotionEvent.ACTION_POINTER_DOWN -> view.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount > 1) { + view.requestDisallowInterceptTouchEvent(true) + } else if (longPressActive) { + view.requestDisallowInterceptTouchEvent(true) + if (abs(event.x - lastLongPressX) >= touchSlop / 2f) { + lastLongPressX = event.x + onLongPressMove(event.x) + } + } else { + val deltaX = abs(event.x - downX) + val deltaY = abs(event.y - downY) + if (deltaY > touchSlop && deltaY > deltaX) { + view.requestDisallowInterceptTouchEvent(false) + } else if (deltaX > touchSlop) { + view.requestDisallowInterceptTouchEvent(true) + } + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + onTouchEnd() + view.requestDisallowInterceptTouchEvent(false) + longPressActive = false + targetView = null + } + } + return false + } +} + +private const val INITIAL_RIGHT_OFFSET = 3f +private const val INITIAL_BAR_SPACING = 10f +private const val MIN_BAR_SPACING = 4f +private const val CHANGE_PERCENT_CALCULATION_SCALE = 8 +private const val CHANGE_PERCENT_DISPLAY_SCALE = 2 +private const val MILLIS_TIMESTAMP_THRESHOLD = 1_000_000_000_000L diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt index 152cc7dca3..08fb752aa3 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpetualContent.kt @@ -292,14 +292,7 @@ fun PerpetualContent( openPositionsPreview.forEachIndexed { index, position -> OpenPositionItem( position = position, - onClick = { - val targetMarket = markets.firstOrNull { it.marketId == position.marketId } - if (targetMarket != null) { - onMarketItemClick(targetMarket) - } else { - onOpenPositionClick(position) - } - } + onClick = { onOpenPositionClick(position) } ) if (index != openPositionsPreview.lastIndex) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 512b3ec1b2..1b95c12d5a 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 @@ -872,7 +872,8 @@ private fun MarketDetailCard( CandleChart( marketId = marketId, timeFrame = timeFrameValues[selectedTimeFrame], - marketPrice = market.last + priceScale = market.priceScale, + marketPrice = market.last, ) } diff --git a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt index 9015752397..19f4d7f2d3 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/LogAndDebugFragment.kt @@ -95,6 +95,17 @@ class LogAndDebugFragment : BaseFragment(R.layout.fragment_log_debug) { webDebug.setOnClickListener { webDebugSc.performClick() } + tradingViewChartSc.isChecked = + defaultSharedPreferences.getBoolean(Constants.Account.PREF_USE_TRADING_VIEW_CANDLES, false) + tradingViewChartSc.setOnCheckedChangeListener { _, isChecked -> + defaultSharedPreferences.putBoolean( + Constants.Account.PREF_USE_TRADING_VIEW_CANDLES, + isChecked, + ) + } + tradingViewChart.setOnClickListener { + tradingViewChartSc.performClick() + } diagnosis.setOnClickListener { navTo(DiagnosisFragment.newInstance(), DiagnosisFragment.TAG) diff --git a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt index 70b917e016..eb8442f190 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/ui/page/AboutPage.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -175,7 +175,7 @@ private fun AboutTile( Box( modifier = Modifier - .height(56.dp) + .heightIn(min = 56.dp) .clickable { onClick() } .padding(horizontal = 16.dp) .fillMaxWidth(), diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index f29ce804df..01e4b2d02a 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -128,4 +128,4 @@ android:textSize="13sp" /> - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_log_debug.xml b/app/src/main/res/layout/fragment_log_debug.xml index 448bcb47fc..dae8d58495 100644 --- a/app/src/main/res/layout/fragment_log_debug.xml +++ b/app/src/main/res/layout/fragment_log_debug.xml @@ -90,6 +90,39 @@ tools:ignore="RelativeOverlap" /> + + + + + + + 一个聊天记录 一个视频 关于 + 使用 TradingView 蜡烛图 + 时间 + + + + + 涨跌 + 涨跌幅 版本 %1$s (%2$d) 接听 禁止访问 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 34739d8ab5..be0dafbe37 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -11,6 +11,14 @@ 一個聊天記錄 一個影片 關於 + 使用 TradingView 蠟燭圖 + 時間 + + + + + 漲跌 + 漲跌幅 版本 %1$s (%2$d) 接聽 禁止訪問 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4816263996..ce997400b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,14 @@ a transcript a video About + Use TradingView candlestick chart + Time + Open + High + Low + Close + Chg + %Chg Version %1$s (%2$d) Accept Access denied diff --git a/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChartTest.kt b/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChartTest.kt new file mode 100644 index 0000000000..f41a5c9157 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChartTest.kt @@ -0,0 +1,128 @@ +package one.mixin.android.ui.home.web3.trade + +import com.tradingview.lightweightcharts.api.series.models.Time +import com.tradingview.lightweightcharts.runtime.plugins.DateTimeFormat +import kotlin.test.Test +import kotlin.test.assertEquals +import one.mixin.android.api.response.perps.CandleItem + +class TradingViewCandleChartTest { + @Test + fun `normalizes millisecond timestamps to seconds`() { + assertEquals(1_721_600_000L, normalizeTradingViewTimestamp(1_721_600_000_000L)) + } + + @Test + fun `keeps second timestamps unchanged`() { + assertEquals(1_721_600_000L, normalizeTradingViewTimestamp(1_721_600_000L)) + } + + @Test + fun `uses time labels for intraday frames`() { + listOf("1m", "5m", "15m", "1h", "4h").forEach { timeFrame -> + assertEquals(DateTimeFormat.TIME, tradingViewDateTimeFormat(timeFrame)) + } + } + + @Test + fun `uses date labels for daily frames`() { + listOf("1d", "1w").forEach { timeFrame -> + assertEquals(DateTimeFormat.DATE, tradingViewDateTimeFormat(timeFrame)) + } + } + + @Test + fun `resolves candle for stationary long press`() { + val timestamp = 1_721_628_800L + val candle = + CandleItem( + timestamp = timestamp, + open = "1", + high = "2", + low = "0.5", + close = "1.5", + volume = "10", + amount = "15", + count = 2, + ) + + assertEquals( + candle, + candleForTradingViewTime(Time.Utc(timestamp), mapOf(timestamp to candle)), + ) + } + + @Test + fun `uses fallback for chart errors without a message`() { + assertEquals("Data error", tradingViewErrorMessage(Throwable(), "Data error")) + } + + @Test + fun `keeps chart error details when available`() { + assertEquals("Chart failed", tradingViewErrorMessage(Throwable("Chart failed"), "Data error")) + } + + @Test + fun `sorts and deduplicates candle timestamps`() { + val duplicate = candle(timestamp = 1, close = "9") + val candles = + tradingViewCandles( + listOf( + candle(timestamp = 3), + candle(timestamp = 1), + duplicate, + candle(timestamp = 2), + ), + ) + + assertEquals(listOf(1L, 2L, 3L), candles.map { (it.data.time as Time.Utc).timestamp }) + assertEquals(duplicate, candles.first().item) + } + + @Test + fun `preserves distinct utc timestamps across daylight saving fallback`() { + val candles = + tradingViewCandles( + listOf( + candle(timestamp = 1_730_610_000L), + candle(timestamp = 1_730_613_600L), + ), + ) + + assertEquals( + listOf(1_730_610_000L, 1_730_613_600L), + candles.map { (it.data.time as Time.Utc).timestamp }, + ) + } + + @Test + fun `drops non-finite candle prices`() { + val candles = + tradingViewCandles( + listOf( + candle(timestamp = 1, open = "NaN"), + candle(timestamp = 2, high = "Infinity"), + candle(timestamp = 3), + ), + ) + + assertEquals(listOf(3L), candles.map { it.item.timestamp }) + } + + private fun candle( + timestamp: Long, + open: String = "1", + high: String = "2", + low: String = "0.5", + close: String = "1.5", + ) = CandleItem( + timestamp = timestamp, + open = open, + high = high, + low = low, + close = close, + volume = "10", + amount = "15", + count = 2, + ) +} diff --git a/build.gradle.kts b/build.gradle.kts index 8451b76fc9..b32df44d39 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,7 @@ extra.set("kotlinVersion", "2.4.10") extra.set("tinkVersion", "0.0.13") extra.set("securityCryptoVersion", "1.1.0") extra.set("webkitVersion", "1.16.0") +extra.set("lightweightChartsVersion", "4.0.0") extra.set("mixinJwtVersion", "2b1c61aa2f") extra.set("alphabetIndexVersion", "1.0.95") extra.set("web3jVersion", "4.12.3-android")