Updated on 2026-08-14
This commit is contained in:
parent
fe1d2342f4
commit
6e69e043c3
7 changed files with 224 additions and 36 deletions
|
|
@ -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
|
||||||
|
|
@ -75,6 +75,7 @@ class TransactionSuspend(
|
||||||
class MarketChartDataProducer private constructor(
|
class MarketChartDataProducer private constructor(
|
||||||
initialData: MarketChartData,
|
initialData: MarketChartData,
|
||||||
initialLook: MarketChartLook,
|
initialLook: MarketChartLook,
|
||||||
|
val pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter,
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
) {
|
) {
|
||||||
internal val startDrawingAnimation = MutableSharedFlow<Unit>()
|
internal val startDrawingAnimation = MutableSharedFlow<Unit>()
|
||||||
|
|
@ -110,13 +111,11 @@ class MarketChartDataProducer private constructor(
|
||||||
startDrawingAnimation.emit(Unit)
|
startDrawingAnimation.emit(Unit)
|
||||||
}
|
}
|
||||||
withContext(dispatcher) {
|
withContext(dispatcher) {
|
||||||
val minX = chartData.x.min()
|
val rawData = pointsValuesConverter.convert(chartData)
|
||||||
val minY = chartData.y.min()
|
|
||||||
|
|
||||||
val normY = chartData.y.map { normalize(it, minY) }
|
val entriesLocal =
|
||||||
val normX = chartData.x.map { normalize(it, minX) }
|
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
|
entries.value = entriesLocal
|
||||||
|
|
||||||
modelProducer.runTransaction {
|
modelProducer.runTransaction {
|
||||||
|
|
@ -160,6 +159,7 @@ class MarketChartDataProducer private constructor(
|
||||||
* @return A MarketChartDataProducer.
|
* @return A MarketChartDataProducer.
|
||||||
*/
|
*/
|
||||||
suspend fun buildSuspend(
|
suspend fun buildSuspend(
|
||||||
|
pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter,
|
||||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
block: TransactionSuspend.() -> Unit,
|
block: TransactionSuspend.() -> Unit,
|
||||||
): MarketChartDataProducer {
|
): MarketChartDataProducer {
|
||||||
|
|
@ -169,6 +169,7 @@ class MarketChartDataProducer private constructor(
|
||||||
initialData = initialData,
|
initialData = initialData,
|
||||||
initialLook = initialLook,
|
initialLook = initialLook,
|
||||||
dispatcher = dispatcher,
|
dispatcher = dispatcher,
|
||||||
|
pointsValuesConverter = pointsValuesConverter,
|
||||||
).apply {
|
).apply {
|
||||||
handleTransactionSuspend(transaction)
|
handleTransactionSuspend(transaction)
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +184,7 @@ class MarketChartDataProducer private constructor(
|
||||||
* @return A MarketChartDataProducer.
|
* @return A MarketChartDataProducer.
|
||||||
*/
|
*/
|
||||||
fun build(
|
fun build(
|
||||||
|
pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter,
|
||||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
block: Transaction.() -> Unit,
|
block: Transaction.() -> Unit,
|
||||||
): MarketChartDataProducer {
|
): MarketChartDataProducer {
|
||||||
|
|
@ -192,16 +194,8 @@ class MarketChartDataProducer private constructor(
|
||||||
initialData = transaction.chartData ?: initialData,
|
initialData = transaction.chartData ?: initialData,
|
||||||
initialLook = transaction.chartLook ?: initialLook,
|
initialLook = transaction.chartLook ?: initialLook,
|
||||||
dispatcher = dispatcher,
|
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
|
@ -75,15 +75,9 @@ class MarketChartState internal constructor(
|
||||||
val state = dataProducer.dataState.value as? MarketChartData.Data
|
val state = dataProducer.dataState.value as? MarketChartData.Data
|
||||||
?: return@CartesianValueFormatter value.toString()
|
?: return@CartesianValueFormatter value.toString()
|
||||||
|
|
||||||
val sMin = state.x.min()
|
lookState.value.xAxisFormatter.format(
|
||||||
val scale = sMin.scale()
|
value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state),
|
||||||
val bVal = if (scale > 2) {
|
)
|
||||||
value.toBigDecimal().movePointLeft(scale - 2) + sMin
|
|
||||||
} else {
|
|
||||||
value.toBigDecimal() + sMin
|
|
||||||
}
|
|
||||||
|
|
||||||
lookState.value.xAxisFormatter.format(bVal)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,15 +86,9 @@ class MarketChartState internal constructor(
|
||||||
val state = dataProducer.dataState.value as? MarketChartData.Data
|
val state = dataProducer.dataState.value as? MarketChartData.Data
|
||||||
?: return@CartesianValueFormatter value.toString()
|
?: return@CartesianValueFormatter value.toString()
|
||||||
|
|
||||||
val sMin = state.y.min()
|
lookState.value.yAxisFormatter.format(
|
||||||
val scale = sMin.scale()
|
value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state),
|
||||||
val bVal = if (scale > 2) {
|
)
|
||||||
value.toBigDecimal().movePointLeft(scale - 2) + sMin
|
|
||||||
} else {
|
|
||||||
value.toBigDecimal() + sMin
|
|
||||||
}
|
|
||||||
|
|
||||||
lookState.value.yAxisFormatter.format(bVal)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,30 @@
|
||||||
package com.tangem.core.ui.haptic
|
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() {
|
override fun vibrateShort() {
|
||||||
/** Intentionnaly do nothing */
|
mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
/** Intentionally do nothing */
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun vibrateMeduim() {
|
override fun vibrateMeduim() {
|
||||||
/** Intentionnaly do nothing */
|
mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
/** Intentionally do nothing */
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun vibrateLong() {
|
override fun vibrateLong() {
|
||||||
/** Intentionnaly do nothing */
|
mockHapticFeedback?.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
/** Intentionally do nothing */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ package com.tangem.core.ui.res
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.runtime.Composable
|
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
|
import com.tangem.core.ui.windowsize.rememberWindowSizePreview
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -20,6 +22,7 @@ fun TangemThemePreview(
|
||||||
typography = typography,
|
typography = typography,
|
||||||
dimens = dimens,
|
dimens = dimens,
|
||||||
windowSize = rememberWindowSizePreview(maxWidth, maxHeight),
|
windowSize = rememberWindowSizePreview(maxWidth, maxHeight),
|
||||||
|
hapticManager = MockHapticManager(LocalHapticFeedback.current),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue