Updated on 2026-08-14

This commit is contained in:
Tangem 2024-06-25 14:15:40 +03:00
parent fe1d2342f4
commit 6e69e043c3
7 changed files with 224 additions and 36 deletions

View file

@ -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

View file

@ -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<Unit>()
@ -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()
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.common.ui.charts.state
import androidx.compose.runtime.Immutable
@Immutable
data class MarketChartRawData(
val y: List<Float>,
val x: List<Float> = List(y.size) { 1f },
)

View file

@ -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),
)
}
}

View file

@ -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()
}
}
}

View file

@ -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 */
}
}

View file

@ -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,
)
}