From 6e69e043c36459ec898fc4a103dd3805ea5d024c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 25 Jun 2024 14:15:40 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../common/ui/charts/MarketChartMini.kt | 111 ++++++++++++++++++ .../charts/state/MarketChartDataProducer.kt | 22 ++-- .../ui/charts/state/MarketChartRawData.kt | 9 ++ .../ui/charts/state/MarketChartState.kt | 24 +--- .../ui/charts/state/PointValuesConverter.kt | 69 +++++++++++ .../core/ui/haptic/MockHapticManager.kt | 22 +++- .../tangem/core/ui/res/TangemThemePreview.kt | 3 + 7 files changed, 224 insertions(+), 36 deletions(-) create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt new file mode 100644 index 0000000000..d9902450d3 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChartMini.kt @@ -0,0 +1,111 @@ +package com.tangem.common.ui.charts + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.* +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec +import com.patrykandpatrick.vico.compose.common.shader.BrushShader +import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout +import com.patrykandpatrick.vico.core.cartesian.Zoom +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel +import com.patrykandpatrick.vico.core.common.shader.ColorShader +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import kotlin.random.Random + +@Composable +fun MarketChartMini( + rawData: MarketChartRawData, + modifier: Modifier = Modifier, + type: MarketChartLook.Type = MarketChartLook.Type.Growing, + growingColor: Color = TangemTheme.colors.icon.accent, + fallingColor: Color = TangemTheme.colors.icon.warning, +) { + val model = remember(rawData) { + CartesianChartModel(LineCartesianLayerModel.build { series(rawData.y) }) + } + + val lineColor = when (type) { + MarketChartLook.Type.Growing -> growingColor + MarketChartLook.Type.Falling -> fallingColor + } + + val lineSpec = rememberLineSpec( + shader = ColorShader(lineColor.toArgb()), + thickness = 1.dp, + backgroundShader = BrushShader( + brush = Brush.verticalGradient( + colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent), + ), + ), + ) + + val layer = rememberLineCartesianLayer(listOf(lineSpec)) + val chart = rememberCartesianChart(layer) + + CartesianChartHost( + modifier = modifier, + chart = chart, + model = model, + zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), + scrollState = rememberVicoScrollState(scrollEnabled = false), + horizontalLayout = HorizontalLayout.fullWidth(), + ) +} + +// region Preview + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + val data = MarketChartRawData( + x = List(20) { Random.nextFloat() }, + y = List(20) { Random.nextFloat() }, + ) + + TangemThemePreview { + Column { + MarketChartMini(rawData = data, type = MarketChartLook.Type.Growing) + SpacerH16() + MarketChartMini(rawData = data, type = MarketChartLook.Type.Falling) + } + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewColumn() { + val data = MarketChartRawData( + x = List(20) { Random.nextFloat() }, + y = List(20) { Random.nextFloat() }, + ) + + TangemThemePreview { + LazyColumn { + items(100) { + MarketChartMini( + rawData = data, + type = if (it % 3 == 0) MarketChartLook.Type.Growing else MarketChartLook.Type.Falling, + ) + } + } + } +} + +// endregion Preview \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt index da62140e59..bcad7d3ceb 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartDataProducer.kt @@ -75,6 +75,7 @@ class TransactionSuspend( class MarketChartDataProducer private constructor( initialData: MarketChartData, initialLook: MarketChartLook, + val pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, private val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { internal val startDrawingAnimation = MutableSharedFlow() @@ -110,13 +111,11 @@ class MarketChartDataProducer private constructor( startDrawingAnimation.emit(Unit) } withContext(dispatcher) { - val minX = chartData.x.min() - val minY = chartData.y.min() + val rawData = pointsValuesConverter.convert(chartData) - val normY = chartData.y.map { normalize(it, minY) } - val normX = chartData.x.map { normalize(it, minX) } + val entriesLocal = + rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) } - val entriesLocal = normX.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, normY[index]) } entries.value = entriesLocal modelProducer.runTransaction { @@ -160,6 +159,7 @@ class MarketChartDataProducer private constructor( * @return A MarketChartDataProducer. */ suspend fun buildSuspend( + pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, dispatcher: CoroutineDispatcher = Dispatchers.Default, block: TransactionSuspend.() -> Unit, ): MarketChartDataProducer { @@ -169,6 +169,7 @@ class MarketChartDataProducer private constructor( initialData = initialData, initialLook = initialLook, dispatcher = dispatcher, + pointsValuesConverter = pointsValuesConverter, ).apply { handleTransactionSuspend(transaction) } @@ -183,6 +184,7 @@ class MarketChartDataProducer private constructor( * @return A MarketChartDataProducer. */ fun build( + pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, dispatcher: CoroutineDispatcher = Dispatchers.Default, block: Transaction.() -> Unit, ): MarketChartDataProducer { @@ -192,16 +194,8 @@ class MarketChartDataProducer private constructor( initialData = transaction.chartData ?: initialData, initialLook = transaction.chartLook ?: initialLook, dispatcher = dispatcher, + pointsValuesConverter = pointsValuesConverter, ) } } -} - -private fun normalize(value: BigDecimal, min: BigDecimal, scale: Int = min.scale()): Float { - val n = value - min - return if (scale > 2) { - n.movePointRight(scale - 2).toFloat() - } else { - n.toFloat() - } } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt new file mode 100644 index 0000000000..a1b7e91f2c --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartRawData.kt @@ -0,0 +1,9 @@ +package com.tangem.common.ui.charts.state + +import androidx.compose.runtime.Immutable + +@Immutable +data class MarketChartRawData( + val y: List, + val x: List = List(y.size) { 1f }, +) \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt index 110161db69..92a0379f5d 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartState.kt @@ -75,15 +75,9 @@ class MarketChartState internal constructor( val state = dataProducer.dataState.value as? MarketChartData.Data ?: return@CartesianValueFormatter value.toString() - val sMin = state.x.min() - val scale = sMin.scale() - val bVal = if (scale > 2) { - value.toBigDecimal().movePointLeft(scale - 2) + sMin - } else { - value.toBigDecimal() + sMin - } - - lookState.value.xAxisFormatter.format(bVal) + lookState.value.xAxisFormatter.format( + value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state), + ) } } @@ -92,15 +86,9 @@ class MarketChartState internal constructor( val state = dataProducer.dataState.value as? MarketChartData.Data ?: return@CartesianValueFormatter value.toString() - val sMin = state.y.min() - val scale = sMin.scale() - val bVal = if (scale > 2) { - value.toBigDecimal().movePointLeft(scale - 2) + sMin - } else { - value.toBigDecimal() + sMin - } - - lookState.value.yAxisFormatter.format(bVal) + lookState.value.yAxisFormatter.format( + value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state), + ) } } diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt new file mode 100644 index 0000000000..1dab1cd0cc --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt @@ -0,0 +1,69 @@ +package com.tangem.common.ui.charts.state + +import java.math.BigDecimal + +/** + * Interface to convert chart data values to Floats and backwards. + * + * We need to convert the values on the graph to floating point values in order to display them correctly on the canvas. + * We also need to determine exactly which floating point value on the graph corresponds to the decimal point, + * so that we can format the actual value and display on the x/y axis. + */ +interface PointValuesConverter { + + fun convert(data: MarketChartData.Data): MarketChartRawData + + fun prepareRawXForFormat(rawX: Float, data: MarketChartData.Data): BigDecimal + + fun prepareRawYForFormat(rawY: Float, data: MarketChartData.Data): BigDecimal +} + +object DefaultPointValuesConverter : PointValuesConverter { + + override fun convert(data: MarketChartData.Data): MarketChartRawData { + val minX = data.x.min() + val minY = data.y.min() + + val normY = data.y.map { normalize(it, minY) } + val normX = data.x.map { normalize(it, minX) } + + return MarketChartRawData( + x = normX, + y = normY, + ) + } + + override fun prepareRawXForFormat(rawX: Float, data: MarketChartData.Data): BigDecimal { + val dataMin = data.x.min() + val scale = dataMin.scale() + val bVal = if (scale > 2) { + rawX.toBigDecimal().movePointLeft(scale - 2) + dataMin + } else { + rawX.toBigDecimal() + dataMin + } + + return bVal + } + + override fun prepareRawYForFormat(rawY: Float, data: MarketChartData.Data): BigDecimal { + val dataMin = data.y.min() + val scale = dataMin.scale() + val bVal = if (scale > 2) { + rawY.toBigDecimal().movePointLeft(scale - 2) + dataMin + } else { + rawY.toBigDecimal() + dataMin + } + + return bVal + } + + // TODO enhance algorithm for values with big difference between min and max, which cannot fit in Float + private fun normalize(value: BigDecimal, min: BigDecimal, scale: Int = min.scale()): Float { + val n = value - min + return if (scale > 2) { + n.movePointRight(scale - 2).toFloat() + } else { + n.toFloat() + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt b/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt index 876ee7a05d..ad74ee1bd1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/haptic/MockHapticManager.kt @@ -1,16 +1,30 @@ package com.tangem.core.ui.haptic -object MockHapticManager : HapticManager { +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType + +@Suppress("FunctionName") +fun MockHapticManager(mockHapticFeedback: HapticFeedback? = null): HapticManager = + if (mockHapticFeedback == null) MockHapticManager else MockHapticManagerImpl(mockHapticFeedback) + +val MockHapticManager: HapticManager = MockHapticManagerImpl() + +private class MockHapticManagerImpl( + private val mockHapticFeedback: HapticFeedback? = null, +) : HapticManager { override fun vibrateShort() { - /** Intentionnaly do nothing */ + mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove) + /** Intentionally do nothing */ } override fun vibrateMeduim() { - /** Intentionnaly do nothing */ + mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove) + /** Intentionally do nothing */ } override fun vibrateLong() { - /** Intentionnaly do nothing */ + mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.LongPress) + /** Intentionally do nothing */ } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt index 48e3237b47..2135bbdff8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemePreview.kt @@ -3,6 +3,8 @@ package com.tangem.core.ui.res import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalHapticFeedback +import com.tangem.core.ui.haptic.MockHapticManager import com.tangem.core.ui.windowsize.rememberWindowSizePreview @Composable @@ -20,6 +22,7 @@ fun TangemThemePreview( typography = typography, dimens = dimens, windowSize = rememberWindowSizePreview(maxWidth, maxHeight), + hapticManager = MockHapticManager(LocalHapticFeedback.current), content = content, ) }