From ddacb27cb901ca36bc7823fd101873433f5ee095 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 22 Jul 2026 12:23:59 +0800 Subject: [PATCH 1/9] feat(perps): add TradingView candle chart Keep the existing renderer as default and expose TradingView through Log & Debug for comparison. --- app/build.gradle.kts | 2 + .../main/java/one/mixin/android/Constants.kt | 1 + .../android/ui/home/web3/trade/CandleChart.kt | 41 +- .../home/web3/trade/TradingViewCandleChart.kt | 531 ++++++++++++++++++ .../web3/trade/perps/PerpsMarketDetailPage.kt | 3 +- .../android/ui/setting/LogAndDebugFragment.kt | 11 + .../main/res/layout/fragment_log_debug.xml | 27 + app/src/main/res/values-zh-rCN/strings.xml | 8 + app/src/main/res/values-zh-rTW/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + build.gradle.kts | 1 + 11 files changed, 634 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChart.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a3cc269039..6caa8e38b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,7 @@ val tweetnaclVersion: String by rootProject.extra val sol4kUtilitiesVersion: String by rootProject.extra val desugarJdkLibsVersion: String by rootProject.extra val bugsnagVersion: String by rootProject.extra +val lightweightChartsVersion: String by rootProject.extra val sumsubVersion: String by rootProject.extra val checkoutFramesVersion: String by rootProject.extra val checkoutSecureVersion: String by rootProject.extra @@ -350,6 +351,7 @@ dependencies { implementation("androidx.biometric:biometric:$biometricVersion") implementation("androidx.security:security-crypto:1.1.0") implementation("androidx.webkit:webkit:1.16.0") + implementation("com.tradingview:lightweightcharts:$lightweightChartsVersion") implementation("org.sol4k:tweetnacl:$tweetnaclVersion") implementation("org.sol4k:utilities:$sol4kUtilitiesVersion") diff --git a/app/src/main/java/one/mixin/android/Constants.kt b/app/src/main/java/one/mixin/android/Constants.kt index e017cbd36d..b6275182b5 100644 --- a/app/src/main/java/one/mixin/android/Constants.kt +++ b/app/src/main/java/one/mixin/android/Constants.kt @@ -130,6 +130,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/TradingViewCandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChart.kt new file mode 100644 index 0000000000..ba69db3f22 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChart.kt @@ -0,0 +1,531 @@ +package one.mixin.android.ui.home.web3.trade + +import android.content.Context +import android.view.GestureDetector +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.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.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 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 chartPriceFormat = + remember(priceScale) { + val precision = priceScale.coerceAtLeast(0) + PriceFormat.priceFormatBuiltIn( + type = PriceFormat.Type.PRICE, + precision = precision, + minMove = BigDecimal.ONE.movePointLeft(precision).toFloat(), + ) + } + val candleData = + remember(candles) { + candles + .firstOrNull() + ?.items + .orEmpty() + .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 + CandlestickData( + time = Time.Utc(item.timestamp), + open = open, + high = high, + low = low, + close = close, + ) + } + } + val candlesByTimestamp = + remember(candles) { + candles.firstOrNull()?.items.orEmpty().associateBy(CandleItem::timestamp) + } + 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 timestamp = (params.time as? Time.Utc)?.timestamp + val candle = timestamp?.let(latestCandlesByTimestamp.value::get) + 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 { + addTouchDelegate( + ChartTouchDelegate( + context = chartContext, + onLongPress = { + isLongPressActive = true + api.setCrosshairVisible(true) + }, + onTouchEnd = { + isLongPressActive = false + selectedCandle = null + api.setCrosshairVisible(false) + }, + ), + ) + subscribeOnChartStateChange { state -> + errorMessage = (state as? ChartsView.State.Error)?.exception?.localizedMessage + } + api.applyOptions { + layout = layoutOptions { + background = SolidColor(backgroundColor.toArgb()) + this.textColor = textColor.toArgb().toIntColor() + } + timeScale = timeScaleOptions { + rightOffset = INITIAL_RIGHT_OFFSET + barSpacing = INITIAL_BAR_SPACING + minBarSpacing = MIN_BAR_SPACING + rightBarStaysOnScroll = true + timeVisible = true + 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(220.dp), + shape = RoundedCornerShape(8.dp), + color = MixinAppTheme.colors.background, + elevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.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 = 11.sp, + maxLines = 1, + modifier = Modifier.width(52.dp), + ) + Text( + text = value, + color = valueColor, + fontSize = 11.sp, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f), + ) + } +} + +private fun ChartApi.setCrosshairVisible(isVisible: Boolean) { + applyOptions { + crosshair = crosshairOptions { + vertLine = crosshairLineOptions { + visible = isVisible + labelVisible = isVisible + } + horzLine = crosshairLineOptions { + visible = isVisible + labelVisible = isVisible + } + } + } +} + +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, +) + +private class ChartTouchDelegate( + context: Context, + private val onLongPress: () -> Unit, + private val onTouchEnd: () -> Unit, +) : TouchDelegate { + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private var downX = 0f + private var downY = 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 + targetView?.requestDisallowInterceptTouchEvent(true) + onLongPress() + } + }, + ) + + override fun beforeTouchEvent(view: ViewGroup) { + targetView = view + } + + override fun onTouchEvent( + view: ViewGroup, + event: MotionEvent, + ): Boolean { + gestureDetector.onTouchEvent(event) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + 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 || longPressActive) { + view.requestDisallowInterceptTouchEvent(true) + } 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/PerpsMarketDetailPage.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsMarketDetailPage.kt index 49f430b22a..bddc97b81e 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 @@ -823,7 +823,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/res/layout/fragment_log_debug.xml b/app/src/main/res/layout/fragment_log_debug.xml index 448bcb47fc..4484e9290d 100644 --- a/app/src/main/res/layout/fragment_log_debug.xml +++ b/app/src/main/res/layout/fragment_log_debug.xml @@ -90,6 +90,33 @@ 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 e62466206d..a37e72f084 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 705fd39867..cf0d519a7f 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/build.gradle.kts b/build.gradle.kts index c9a254d9ff..81028ed17b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -154,6 +154,7 @@ val tweetnaclVersion by extra("0.1.6") val sol4kUtilitiesVersion by extra("0.1.0") val desugarJdkLibsVersion by extra("2.1.5") val bugsnagVersion by extra("6.26.0") +val lightweightChartsVersion by extra("4.0.0") allprojects { repositories { From 6671a222dc74db42459b52e1584cd50f9fd4c1bf Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 22 Jul 2026 17:45:05 +0800 Subject: [PATCH 2/9] fix(perps): update batch close analytics (#6515) --- .../ui/home/web3/trade/perps/AllPositionsFragment.kt | 2 +- .../perps/PerpsBatchCloseBottomSheetDialogFragment.kt | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt index f9e7620939..bb458e5051 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/AllPositionsFragment.kt @@ -112,7 +112,7 @@ class AllPositionsFragment : BaseFragment() { } }, onCloseAllPositions = { positions -> - AnalyticsTracker.trackPerpsClosePositionStart() + AnalyticsTracker.trackPerpsCloseStart(AnalyticsTracker.PerpsCloseType.MULTIPLE) PerpsBatchCloseBottomSheetDialogFragment.newInstance(positions) .show(parentFragmentManager, PerpsBatchCloseBottomSheetDialogFragment.TAG) }, diff --git a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt index eb0626545f..f93fe11d33 100644 --- a/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/home/web3/trade/perps/PerpsBatchCloseBottomSheetDialogFragment.kt @@ -146,7 +146,6 @@ class PerpsBatchCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFr LaunchedEffect(Unit) { remainingPositions = positions refreshPaymentDetails() - AnalyticsTracker.trackPerpsClosePositionPreview() } val displayPositions = if (step == Step.Done) positions else remainingPositions @@ -269,11 +268,11 @@ class PerpsBatchCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFr cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(R.string.Retry), cancelAction = { - AnalyticsTracker.trackPerpsClosePositionPreviewCancel() + AnalyticsTracker.trackPerpsClosePreviewCancel() dismiss() }, confirmAction = { - AnalyticsTracker.trackPerpsClosePositionPreviewConfirm() + AnalyticsTracker.trackPerpsClosePreviewConfirm() showVerifyPinThenClose() }, ) @@ -285,11 +284,11 @@ class PerpsBatchCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFr cancelTitle = stringResource(R.string.Cancel), confirmTitle = stringResource(R.string.Confirm), cancelAction = { - AnalyticsTracker.trackPerpsClosePositionPreviewCancel() + AnalyticsTracker.trackPerpsClosePreviewCancel() dismiss() }, confirmAction = { - AnalyticsTracker.trackPerpsClosePositionPreviewConfirm() + AnalyticsTracker.trackPerpsClosePreviewConfirm() showVerifyPinThenClose() }, ) @@ -339,7 +338,7 @@ class PerpsBatchCloseBottomSheetDialogFragment : MixinComposeBottomSheetDialogFr viewModel.closePerpsOrders(attemptedPositions) { result -> val successfulCount = attemptedPositions.size - result.failedPositions.size if (successfulCount > 0) { - AnalyticsTracker.trackPerpsClosePositionEnd() + AnalyticsTracker.trackPerpsCloseEnd() step = Step.Done } else { remainingPositions = result.failedPositions From df82d746125a14ede377503f805aa43dc5f366c7 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 22 Jul 2026 18:12:32 +0800 Subject: [PATCH 3/9] fix(perps): open position market from database --- .../ui/home/web3/trade/TradeFragment.kt | 20 ++++++++++++------- .../home/web3/trade/perps/PerpetualContent.kt | 9 +-------- 2 files changed, 14 insertions(+), 15 deletions(-) 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/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)) From 38048a3e318c18f36aecaa74f4bd42fde51a8a68 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 22 Jul 2026 18:23:25 +0800 Subject: [PATCH 4/9] fix(perps): correct chart time labels --- .../home/web3/trade/TradingViewCandleChart.kt | 44 ++++++++++++---- .../web3/trade/TradingViewCandleChartTest.kt | 52 +++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 app/src/test/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChartTest.kt 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 index ba69db3f22..8df7e04039 100644 --- 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 @@ -57,6 +57,7 @@ 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.view.ChartsView import com.tradingview.lightweightcharts.view.gesture.TouchDelegate import one.mixin.android.Constants @@ -89,6 +90,8 @@ fun TradingViewCandleChart( val textColor = MixinAppTheme.colors.textAssist val gridColor = MixinAppTheme.colors.borderColor val crosshairColor = MixinAppTheme.colors.textAssist + val dateTimeFormat = remember(timeFrame) { tradingViewDateTimeFormat(timeFrame) } + val zoneId = remember { ZoneId.systemDefault() } val chartPriceFormat = remember(priceScale) { val precision = priceScale.coerceAtLeast(0) @@ -110,7 +113,7 @@ fun TradingViewCandleChart( val low = item.low.toFloatOrNull() ?: return@mapNotNull null val close = item.close.toFloatOrNull() ?: return@mapNotNull null CandlestickData( - time = Time.Utc(item.timestamp), + time = Time.Utc(tradingViewLocalTimestamp(item.timestamp, zoneId)), open = open, high = high, low = low, @@ -120,7 +123,9 @@ fun TradingViewCandleChart( } val candlesByTimestamp = remember(candles) { - candles.firstOrNull()?.items.orEmpty().associateBy(CandleItem::timestamp) + candles.firstOrNull()?.items.orEmpty().associateBy { item -> + tradingViewLocalTimestamp(item.timestamp, zoneId) + } } val latestCandlesByTimestamp = rememberUpdatedState(candlesByTimestamp) val currentPrice = marketPrice?.toFloatOrNull() @@ -236,7 +241,8 @@ fun TradingViewCandleChart( barSpacing = INITIAL_BAR_SPACING minBarSpacing = MIN_BAR_SPACING rightBarStaysOnScroll = true - timeVisible = true + timeVisible = dateTimeFormat == DateTimeFormat.TIME + secondsVisible = false borderColor = gridColor.toArgb().toIntColor() } rightPriceScale = priceScaleOptions { @@ -328,14 +334,14 @@ private fun CandleDetailsTooltip( val changeColor = if (change == null || change >= BigDecimal.ZERO) upColor else downColor Surface( - modifier = modifier.width(220.dp), - shape = RoundedCornerShape(8.dp), + modifier = modifier.width(180.dp), + shape = RoundedCornerShape(6.dp), color = MixinAppTheme.colors.background, elevation = 3.dp, ) { Column( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), ) { CandleDetailsRow( label = stringResource(R.string.Candle_Time), @@ -386,14 +392,14 @@ private fun CandleDetailsRow( Text( text = "$label:", color = MixinAppTheme.colors.textAssist, - fontSize = 11.sp, + fontSize = 10.sp, maxLines = 1, - modifier = Modifier.width(52.dp), + modifier = Modifier.width(48.dp), ) Text( text = value, color = valueColor, - fontSize = 11.sp, + fontSize = 10.sp, maxLines = 1, softWrap = false, overflow = TextOverflow.Clip, @@ -456,6 +462,24 @@ private data class SelectedCandle( val pointX: Float, ) +internal fun normalizeTradingViewTimestamp(timestamp: Long): Long = + if (timestamp >= MILLIS_TIMESTAMP_THRESHOLD) timestamp / 1000 else timestamp + +internal fun tradingViewLocalTimestamp( + timestamp: Long, + zoneId: ZoneId = ZoneId.systemDefault(), +): Long { + val utcTimestamp = normalizeTradingViewTimestamp(timestamp) + val offsetSeconds = Instant.ofEpochSecond(utcTimestamp).atZone(zoneId).offset.totalSeconds + return utcTimestamp + offsetSeconds +} + +internal fun tradingViewDateTimeFormat(timeFrame: String): DateTimeFormat = + when (timeFrame.lowercase()) { + "1d", "1w" -> DateTimeFormat.DATE + else -> DateTimeFormat.TIME + } + private class ChartTouchDelegate( context: Context, private val onLongPress: () -> Unit, 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..c326322613 --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChartTest.kt @@ -0,0 +1,52 @@ +package one.mixin.android.ui.home.web3.trade + +import com.tradingview.lightweightcharts.runtime.plugins.DateTimeFormat +import kotlin.test.Test +import kotlin.test.assertEquals +import org.threeten.bp.ZoneOffset + +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 `converts utc timestamp to local chart timestamp`() { + val utcTimestamp = 1_721_600_000L + + assertEquals( + utcTimestamp + 8 * 60 * 60, + tradingViewLocalTimestamp(utcTimestamp, ZoneOffset.ofHours(8)), + ) + } + + @Test + fun `converts millisecond timestamp to local chart timestamp`() { + val utcTimestampMillis = 1_721_600_000_000L + + assertEquals( + 1_721_600_000L + 8 * 60 * 60, + tradingViewLocalTimestamp(utcTimestampMillis, ZoneOffset.ofHours(8)), + ) + } +} From a8f23fefbbb7b1f4f0a85bcb28851530971fc2ce Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 22 Jul 2026 18:36:37 +0800 Subject: [PATCH 5/9] style(perps): compact chart details tooltip --- .../home/web3/trade/TradingViewCandleChart.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 index 8df7e04039..350848b8cc 100644 --- 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 @@ -334,14 +334,14 @@ private fun CandleDetailsTooltip( val changeColor = if (change == null || change >= BigDecimal.ZERO) upColor else downColor Surface( - modifier = modifier.width(180.dp), - shape = RoundedCornerShape(6.dp), + modifier = modifier.width(140.dp), + shape = RoundedCornerShape(4.dp), color = MixinAppTheme.colors.background, - elevation = 3.dp, + elevation = 2.dp, ) { Column( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - verticalArrangement = Arrangement.spacedBy(1.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), ) { CandleDetailsRow( label = stringResource(R.string.Candle_Time), @@ -392,14 +392,16 @@ private fun CandleDetailsRow( Text( text = "$label:", color = MixinAppTheme.colors.textAssist, - fontSize = 10.sp, + fontSize = 9.sp, + lineHeight = 12.sp, maxLines = 1, - modifier = Modifier.width(48.dp), + modifier = Modifier.width(40.dp), ) Text( text = value, color = valueColor, - fontSize = 10.sp, + fontSize = 9.sp, + lineHeight = 12.sp, maxLines = 1, softWrap = false, overflow = TextOverflow.Clip, From 6b75d67e4318657057313a675ebe540f19bbc6d1 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 22 Jul 2026 19:20:11 +0800 Subject: [PATCH 6/9] fix(perps): stabilize chart touch gestures --- .../home/web3/trade/TradingViewCandleChart.kt | 74 +++++++++++++------ .../web3/trade/TradingViewCandleChartTest.kt | 28 +++++++ 2 files changed, 79 insertions(+), 23 deletions(-) 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 index 350848b8cc..1026f0a588 100644 --- 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 @@ -1,7 +1,9 @@ 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 @@ -142,8 +144,7 @@ fun TradingViewCandleChart( remember { { params: MouseEventParams -> if (isLongPressActive) { - val timestamp = (params.time as? Time.Utc)?.timestamp - val candle = timestamp?.let(latestCandlesByTimestamp.value::get) + val candle = candleForTradingViewTime(params.time, latestCandlesByTimestamp.value) selectedCandle = if (candle == null) { null @@ -214,17 +215,30 @@ fun TradingViewCandleChart( 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, - onLongPress = { + onTouchStart = { + isLongPressActive = false + selectedCandle = null + }, + onLongPress = { pointX -> isLongPressActive = true - api.setCrosshairVisible(true) + selectCandleAt(pointX) }, + onLongPressMove = selectCandleAt, onTouchEnd = { isLongPressActive = false selectedCandle = null - api.setCrosshairVisible(false) }, ), ) @@ -411,21 +425,6 @@ private fun CandleDetailsRow( } } -private fun ChartApi.setCrosshairVisible(isVisible: Boolean) { - applyOptions { - crosshair = crosshairOptions { - vertLine = crosshairLineOptions { - visible = isVisible - labelVisible = isVisible - } - horzLine = crosshairLineOptions { - visible = isVisible - labelVisible = isVisible - } - } - } -} - private fun formatCandlePrice( value: String, priceScale: Int, @@ -482,14 +481,28 @@ internal fun tradingViewDateTimeFormat(timeFrame: String): DateTimeFormat = else -> DateTimeFormat.TIME } +internal fun candleForTradingViewTime( + time: Time?, + candlesByTimestamp: Map, +): CandleItem? = (time as? Time.Utc)?.timestamp?.let(candlesByTimestamp::get) + +internal fun tradingViewTouchCoordinate( + touchX: Float, + density: Float, +): Float = if (density > 0f) touchX / density else touchX + private class ChartTouchDelegate( context: Context, - private val onLongPress: () -> Unit, + 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 val density = context.resources.displayMetrics.density private var downX = 0f private var downY = 0f + private var lastLongPressX = 0f private var longPressActive = false private var targetView: ViewGroup? = null private val gestureDetector = @@ -500,8 +513,16 @@ private class ChartTouchDelegate( override fun onLongPress(e: MotionEvent) { longPressActive = true + lastLongPressX = e.x targetView?.requestDisallowInterceptTouchEvent(true) - onLongPress() + targetView?.performHapticFeedback( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + HapticFeedbackConstants.GESTURE_START + } else { + HapticFeedbackConstants.LONG_PRESS + }, + ) + onLongPress(tradingViewTouchCoordinate(e.x, density)) } }, ) @@ -517,6 +538,7 @@ private class ChartTouchDelegate( gestureDetector.onTouchEvent(event) when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { + onTouchStart() downX = event.x downY = event.y longPressActive = false @@ -525,8 +547,14 @@ private class ChartTouchDelegate( MotionEvent.ACTION_POINTER_DOWN -> view.requestDisallowInterceptTouchEvent(true) MotionEvent.ACTION_MOVE -> { - if (event.pointerCount > 1 || longPressActive) { + if (event.pointerCount > 1) { view.requestDisallowInterceptTouchEvent(true) + } else if (longPressActive) { + view.requestDisallowInterceptTouchEvent(true) + if (abs(event.x - lastLongPressX) >= touchSlop / 2f) { + lastLongPressX = event.x + onLongPressMove(tradingViewTouchCoordinate(event.x, density)) + } } else { val deltaX = abs(event.x - downX) val deltaY = abs(event.y - downY) 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 index c326322613..e292d0aacb 100644 --- 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 @@ -1,8 +1,10 @@ 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 import org.threeten.bp.ZoneOffset class TradingViewCandleChartTest { @@ -49,4 +51,30 @@ class TradingViewCandleChartTest { tradingViewLocalTimestamp(utcTimestampMillis, ZoneOffset.ofHours(8)), ) } + + @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 `converts android touch position to chart coordinate`() { + assertEquals(100f, tradingViewTouchCoordinate(touchX = 300f, density = 3f)) + } } From 4669ca840c59d1d59d8380a45b3e903dfffbf7c8 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 27 Jul 2026 17:09:37 +0800 Subject: [PATCH 7/9] fix(perps): sanitize TradingView candle data TradingView rejects duplicate timestamps and crashes on malformed series input, so normalize server candle responses before setData. --- .../home/web3/trade/TradingViewCandleChart.kt | 65 +++++++++++++------ .../web3/trade/TradingViewCandleChartTest.kt | 50 ++++++++++++++ 2 files changed, 94 insertions(+), 21 deletions(-) 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 index 1026f0a588..f0adc62f9f 100644 --- 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 @@ -103,30 +103,15 @@ fun TradingViewCandleChart( minMove = BigDecimal.ONE.movePointLeft(precision).toFloat(), ) } - val candleData = + val chartCandles = remember(candles) { - candles - .firstOrNull() - ?.items - .orEmpty() - .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 - CandlestickData( - time = Time.Utc(tradingViewLocalTimestamp(item.timestamp, zoneId)), - open = open, - high = high, - low = low, - close = close, - ) - } + tradingViewCandles(candles.firstOrNull()?.items.orEmpty(), zoneId) } + val candleData = chartCandles.map { it.data } val candlesByTimestamp = - remember(candles) { - candles.firstOrNull()?.items.orEmpty().associateBy { item -> - tradingViewLocalTimestamp(item.timestamp, zoneId) + remember(chartCandles) { + chartCandles.associate { candle -> + (candle.data.time as Time.Utc).timestamp to candle.item } } val latestCandlesByTimestamp = rememberUpdatedState(candlesByTimestamp) @@ -475,6 +460,44 @@ internal fun tradingViewLocalTimestamp( return utcTimestamp + offsetSeconds } +internal data class TradingViewCandle( + val item: CandleItem, + val data: CandlestickData, +) + +internal fun tradingViewCandles( + items: List, + zoneId: ZoneId = ZoneId.systemDefault(), +): 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 = + runCatching { tradingViewLocalTimestamp(item.timestamp, zoneId) } + .getOrNull() + ?: return@mapNotNull null + 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 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 index e292d0aacb..d716324c09 100644 --- 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 @@ -77,4 +77,54 @@ class TradingViewCandleChartTest { fun `converts android touch position to chart coordinate`() { assertEquals(100f, tradingViewTouchCoordinate(touchX = 300f, density = 3f)) } + + @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), + ), + ZoneOffset.UTC, + ) + + assertEquals(listOf(1L, 2L, 3L), candles.map { (it.data.time as Time.Utc).timestamp }) + assertEquals(duplicate, candles.first().item) + } + + @Test + fun `drops non-finite candle prices`() { + val candles = + tradingViewCandles( + listOf( + candle(timestamp = 1, open = "NaN"), + candle(timestamp = 2, high = "Infinity"), + candle(timestamp = 3), + ), + ZoneOffset.UTC, + ) + + 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, + ) } From db62245acfc8a4e4bad9f5e44f86c49017e9c718 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Mon, 27 Jul 2026 17:36:09 +0800 Subject: [PATCH 8/9] fix(perps): harden TradingView chart integration --- ...ing_view_local_time_tick_mark_formatter.js | 32 +++++++++ .../home/web3/trade/TradingViewCandleChart.kt | 67 ++++++++++++------- .../mixin/android/ui/setting/AboutFragment.kt | 1 + .../android/ui/setting/ui/page/AboutPage.kt | 10 ++- app/src/main/res/layout/fragment_about.xml | 17 ++++- .../main/res/layout/fragment_log_debug.xml | 12 +++- app/src/main/res/values/strings.xml | 2 + .../web3/trade/TradingViewCandleChartTest.kt | 48 +++++++------ 8 files changed, 134 insertions(+), 55 deletions(-) create mode 100644 app/src/main/assets/trading_view_local_time_tick_mark_formatter.js 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/ui/home/web3/trade/TradingViewCandleChart.kt b/app/src/main/java/one/mixin/android/ui/home/web3/trade/TradingViewCandleChart.kt index f0adc62f9f..9c5eb8b3e9 100644 --- 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 @@ -51,6 +51,7 @@ 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 @@ -60,6 +61,8 @@ 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 @@ -70,6 +73,7 @@ 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 @@ -93,7 +97,21 @@ fun TradingViewCandleChart( val gridColor = MixinAppTheme.colors.borderColor val crosshairColor = MixinAppTheme.colors.textAssist val dateTimeFormat = remember(timeFrame) { tradingViewDateTimeFormat(timeFrame) } - val zoneId = remember { ZoneId.systemDefault() } + 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) @@ -105,7 +123,7 @@ fun TradingViewCandleChart( } val chartCandles = remember(candles) { - tradingViewCandles(candles.firstOrNull()?.items.orEmpty(), zoneId) + tradingViewCandles(candles.firstOrNull()?.items.orEmpty()) } val candleData = chartCandles.map { it.data } val candlesByTimestamp = @@ -228,13 +246,21 @@ fun TradingViewCandleChart( ), ) subscribeOnChartStateChange { state -> - errorMessage = (state as? ChartsView.State.Error)?.exception?.localizedMessage + 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 @@ -242,6 +268,7 @@ fun TradingViewCandleChart( rightBarStaysOnScroll = true timeVisible = dateTimeFormat == DateTimeFormat.TIME secondsVisible = false + this.tickMarkFormatter = tickMarkFormatter borderColor = gridColor.toArgb().toIntColor() } rightPriceScale = priceScaleOptions { @@ -451,15 +478,6 @@ private data class SelectedCandle( internal fun normalizeTradingViewTimestamp(timestamp: Long): Long = if (timestamp >= MILLIS_TIMESTAMP_THRESHOLD) timestamp / 1000 else timestamp -internal fun tradingViewLocalTimestamp( - timestamp: Long, - zoneId: ZoneId = ZoneId.systemDefault(), -): Long { - val utcTimestamp = normalizeTradingViewTimestamp(timestamp) - val offsetSeconds = Instant.ofEpochSecond(utcTimestamp).atZone(zoneId).offset.totalSeconds - return utcTimestamp + offsetSeconds -} - internal data class TradingViewCandle( val item: CandleItem, val data: CandlestickData, @@ -467,7 +485,6 @@ internal data class TradingViewCandle( internal fun tradingViewCandles( items: List, - zoneId: ZoneId = ZoneId.systemDefault(), ): List = items.mapNotNull { item -> val open = item.open.toFloatOrNull() ?: return@mapNotNull null @@ -477,10 +494,7 @@ internal fun tradingViewCandles( if (!open.isFinite() || !high.isFinite() || !low.isFinite() || !close.isFinite()) { return@mapNotNull null } - val timestamp = - runCatching { tradingViewLocalTimestamp(item.timestamp, zoneId) } - .getOrNull() - ?: return@mapNotNull null + val timestamp = normalizeTradingViewTimestamp(item.timestamp) TradingViewCandle( item = item, data = @@ -509,10 +523,16 @@ internal fun candleForTradingViewTime( candlesByTimestamp: Map, ): CandleItem? = (time as? Time.Utc)?.timestamp?.let(candlesByTimestamp::get) -internal fun tradingViewTouchCoordinate( - touchX: Float, - density: Float, -): Float = if (density > 0f) touchX / density else touchX +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, @@ -522,7 +542,6 @@ private class ChartTouchDelegate( private val onTouchEnd: () -> Unit, ) : TouchDelegate { private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop - private val density = context.resources.displayMetrics.density private var downX = 0f private var downY = 0f private var lastLongPressX = 0f @@ -545,7 +564,7 @@ private class ChartTouchDelegate( HapticFeedbackConstants.LONG_PRESS }, ) - onLongPress(tradingViewTouchCoordinate(e.x, density)) + onLongPress(e.x) } }, ) @@ -576,7 +595,7 @@ private class ChartTouchDelegate( view.requestDisallowInterceptTouchEvent(true) if (abs(event.x - lastLongPressX) >= touchSlop / 2f) { lastLongPressX = event.x - onLongPressMove(tradingViewTouchCoordinate(event.x, density)) + onLongPressMove(event.x) } } else { val deltaX = abs(event.x - downX) diff --git a/app/src/main/java/one/mixin/android/ui/setting/AboutFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/AboutFragment.kt index 562f42c28a..2096443d31 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/AboutFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/AboutFragment.kt @@ -60,6 +60,7 @@ class AboutFragment : BaseFragment(R.layout.fragment_about) { terms.setOnClickListener { context?.openUrl(getString(R.string.landing_terms_url)) } privacy.setOnClickListener { context?.openUrl(getString(R.string.landing_privacy_policy_url)) } checkUpdates.setOnClickListener { context?.openMarket() } + tradingViewAttribution.setOnClickListener { context?.openUrl(getString(R.string.trading_view_url)) } openSource.setOnClickListener { context?.openUrl("https://github.com/MixinNetwork/android-app") } logAndDebug.setOnClickListener { navTo(LogAndDebugFragment.newInstance(), LogAndDebugFragment.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..2b8d8495ab 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 @@ -155,6 +155,12 @@ fun AboutPage() { context.openMarket() }, ) + AboutTile( + text = stringResource(id = R.string.trading_view_attribution), + onClick = { + context.openUrl(context.getString(R.string.trading_view_url)) + }, + ) if (showLogDebug.value) { AboutTile( text = stringResource(id = R.string.LogAndDebug), @@ -175,7 +181,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..6a00b5400e 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -102,6 +102,21 @@ android:textColor="@color/colorBlue" android:textSize="16sp" /> + + - \ 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 4484e9290d..dae8d58495 100644 --- a/app/src/main/res/layout/fragment_log_debug.xml +++ b/app/src/main/res/layout/fragment_log_debug.xml @@ -98,14 +98,20 @@ android:foreground="?android:attr/selectableItemBackground"> + android:textSize="16sp" /> + android:contentDescription="@string/Use_TradingView_Candlestick_Chart" /> a video About Use TradingView candlestick chart + TradingView Lightweight Charts\nCopyright (с) 2022 TradingView, Inc. https://www.tradingview.com/ + https://www.tradingview.com/ Time Open High 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 index d716324c09..f41a5c9157 100644 --- 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 @@ -5,7 +5,6 @@ import com.tradingview.lightweightcharts.runtime.plugins.DateTimeFormat import kotlin.test.Test import kotlin.test.assertEquals import one.mixin.android.api.response.perps.CandleItem -import org.threeten.bp.ZoneOffset class TradingViewCandleChartTest { @Test @@ -32,26 +31,6 @@ class TradingViewCandleChartTest { } } - @Test - fun `converts utc timestamp to local chart timestamp`() { - val utcTimestamp = 1_721_600_000L - - assertEquals( - utcTimestamp + 8 * 60 * 60, - tradingViewLocalTimestamp(utcTimestamp, ZoneOffset.ofHours(8)), - ) - } - - @Test - fun `converts millisecond timestamp to local chart timestamp`() { - val utcTimestampMillis = 1_721_600_000_000L - - assertEquals( - 1_721_600_000L + 8 * 60 * 60, - tradingViewLocalTimestamp(utcTimestampMillis, ZoneOffset.ofHours(8)), - ) - } - @Test fun `resolves candle for stationary long press`() { val timestamp = 1_721_628_800L @@ -74,8 +53,13 @@ class TradingViewCandleChartTest { } @Test - fun `converts android touch position to chart coordinate`() { - assertEquals(100f, tradingViewTouchCoordinate(touchX = 300f, density = 3f)) + 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 @@ -89,13 +73,28 @@ class TradingViewCandleChartTest { duplicate, candle(timestamp = 2), ), - ZoneOffset.UTC, ) 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 = @@ -105,7 +104,6 @@ class TradingViewCandleChartTest { candle(timestamp = 2, high = "Infinity"), candle(timestamp = 3), ), - ZoneOffset.UTC, ) assertEquals(listOf(3L), candles.map { it.item.timestamp }) From 8437d5f6dbc730e8e2322d93cb7fe73bc0fc9689 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 31 Jul 2026 09:56:53 +0800 Subject: [PATCH 9/9] chore(ui): remove TradingView attribution --- .../one/mixin/android/ui/setting/AboutFragment.kt | 1 - .../mixin/android/ui/setting/ui/page/AboutPage.kt | 6 ------ app/src/main/res/layout/fragment_about.xml | 15 --------------- app/src/main/res/values/strings.xml | 2 -- 4 files changed, 24 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/setting/AboutFragment.kt b/app/src/main/java/one/mixin/android/ui/setting/AboutFragment.kt index 2096443d31..562f42c28a 100644 --- a/app/src/main/java/one/mixin/android/ui/setting/AboutFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/setting/AboutFragment.kt @@ -60,7 +60,6 @@ class AboutFragment : BaseFragment(R.layout.fragment_about) { terms.setOnClickListener { context?.openUrl(getString(R.string.landing_terms_url)) } privacy.setOnClickListener { context?.openUrl(getString(R.string.landing_privacy_policy_url)) } checkUpdates.setOnClickListener { context?.openMarket() } - tradingViewAttribution.setOnClickListener { context?.openUrl(getString(R.string.trading_view_url)) } openSource.setOnClickListener { context?.openUrl("https://github.com/MixinNetwork/android-app") } logAndDebug.setOnClickListener { navTo(LogAndDebugFragment.newInstance(), LogAndDebugFragment.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 2b8d8495ab..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 @@ -155,12 +155,6 @@ fun AboutPage() { context.openMarket() }, ) - AboutTile( - text = stringResource(id = R.string.trading_view_attribution), - onClick = { - context.openUrl(context.getString(R.string.trading_view_url)) - }, - ) if (showLogDebug.value) { AboutTile( text = stringResource(id = R.string.LogAndDebug), diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 6a00b5400e..01e4b2d02a 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -102,21 +102,6 @@ android:textColor="@color/colorBlue" android:textSize="16sp" /> - - a video About Use TradingView candlestick chart - TradingView Lightweight Charts\nCopyright (с) 2022 TradingView, Inc. https://www.tradingview.com/ - https://www.tradingview.com/ Time Open High