From e20f93208128c352c0b2168536c412333a7a6983 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 2 Aug 2024 11:24:49 +0300 Subject: [PATCH] Updated on 2026-08-14 --- build.gradle.kts | 5 + common/ui-charts/build.gradle.kts | 1 + .../tangem/common/ui/charts/MarketChart.kt | 195 +++++++------- .../common/ui/charts/MarketChartMini.kt | 32 +-- .../ui/charts/downsample/LTThreeBuckets.kt | 246 ++++++++++++++++++ .../common/ui/charts/layer/ChartMarker.kt | 112 ++++++++ .../ui/charts/layer/MarketChartLayer.kt | 227 +++++----------- .../common/ui/charts/layer/TimeItemPlacer.kt | 56 ++++ .../common/ui/charts/marker/ChartMarker.kt | 146 ----------- .../common/ui/charts/state/MarketChartData.kt | 6 +- .../charts/state/MarketChartDataProducer.kt | 85 +++--- .../common/ui/charts/state/MarketChartLook.kt | 8 +- .../ui/charts/state/MarketChartRawData.kt | 15 +- .../ui/charts/state/MarketChartState.kt | 75 +++--- .../ui/charts/state/PointValuesConverter.kt | 69 ----- .../state/converter/PointValuesConverter.kt | 23 ++ .../PriceAndTimePointValuesConverter.kt | 108 ++++++++ .../{ => formatter}/AxisLabelFormatter.kt | 4 +- .../state/formatter/FormatterWrapWithCache.kt | 15 ++ .../api/markets/TangemTechMarketsApi.kt | 1 + .../com/tangem/core/ui/res/TangemTheme.kt | 5 + .../core/ui/utils/DateTimeFormatters.kt | 9 +- .../markets/DefaultMarketsTokenRepository.kt | 9 +- .../converters/TokenListConfigConverters.kt | 2 +- .../converters/TokenMarketChartsConverter.kt | 21 +- domain/app-currency/models/build.gradle.kts | 5 + .../domain/appcurrency/model/AppCurrency.kt | 3 + domain/markets/build.gradle.kts | 3 +- .../markets/TokenMarketUpdateRequest.kt | 2 +- .../markets/GetTokenPriceChartUseCase.kt | 13 +- .../repositories/MarketsTokenRepository.kt | 2 +- .../markets/DefaultMarketsEntryComponent.kt | 107 ++++++-- .../markets/MarketsEntryChildFactory.kt | 9 +- .../api/MarketsTokenDetailsComponent.kt | 27 +- .../DefaultMarketsTokenDetailsComponent.kt | 14 +- .../impl/model/MarketsTokenDetailsModel.kt | 210 ++++++++++++++- .../impl/ui/MarketsTokenDetailsContent.kt | 204 ++++++++++++++- .../ui/components/MarketTokenDetailsChart.kt | 69 ++++- .../impl/ui/entity/MarketsTokenDetailsUM.kt | 17 +- .../api/MarketsTokenListComponent.kt | 6 +- .../impl/DefaultMarketsTokenListComponent.kt | 7 +- .../tokenlist/impl/model/MarketsListModel.kt | 4 +- .../converters/MarketsTokenItemConverter.kt | 11 +- .../MarketsListBatchFlowManager.kt | 10 +- .../markets/tokenlist/impl/ui/MarketsList.kt | 98 ++++--- .../ui/components/MarketsListLazyColumn.kt | 2 +- .../MarketChartListItemPreviewDataProvider.kt | 11 +- .../wallet/presentation/WalletFragment.kt | 7 +- .../wallet/state/model/WalletScreenState.kt | 2 + .../presentation/wallet/ui/WalletScreen.kt | 167 ++++++------ .../wallet/viewmodels/WalletViewModel.kt | 2 + gradle/dependencies.toml | 2 +- 52 files changed, 1676 insertions(+), 813 deletions(-) create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt delete mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt delete mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt rename common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/{ => formatter}/AxisLabelFormatter.kt (76%) create mode 100644 common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt diff --git a/build.gradle.kts b/build.gradle.kts index d92b770060..e71d217293 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -94,6 +94,11 @@ val generateComposeMetrics by tasks.registering { "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$outputDirectory", ) + // Compose strong skipping mode + // freeCompilerArgs.addAll( + // "-P", + // "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true", + // ) } } } diff --git a/common/ui-charts/build.gradle.kts b/common/ui-charts/build.gradle.kts index 319c15eebc..58bf27c3a5 100644 --- a/common/ui-charts/build.gradle.kts +++ b/common/ui-charts/build.gradle.kts @@ -22,4 +22,5 @@ dependencies { implementation(deps.compose.material3) implementation(deps.compose.ui.tooling) implementation(deps.compose.ui.utils) + implementation(deps.kotlin.immutable.collections) } \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt index 58c95c1451..3dca540806 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/MarketChart.kt @@ -3,7 +3,6 @@ package com.tangem.common.ui.charts import android.content.res.Configuration import androidx.annotation.FloatRange import androidx.compose.foundation.background -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -12,10 +11,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontSynthesis import androidx.compose.ui.text.font.FontWeight @@ -23,26 +23,32 @@ import androidx.compose.ui.text.font.resolveAsTypeface import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.patrykandpatrick.vico.compose.cartesian.* +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisGuidelineComponent import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottomAxis import com.patrykandpatrick.vico.compose.cartesian.axis.rememberCustomStartAxis +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState import com.patrykandpatrick.vico.compose.common.of import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout import com.patrykandpatrick.vico.core.cartesian.Zoom -import com.patrykandpatrick.vico.core.cartesian.axis.* +import com.patrykandpatrick.vico.core.cartesian.axis.AxisPosition +import com.patrykandpatrick.vico.core.cartesian.axis.BaseAxis +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter -import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener import com.patrykandpatrick.vico.core.cartesian.marker.LineCartesianLayerMarkerTarget import com.patrykandpatrick.vico.core.common.Dimensions import com.patrykandpatrick.vico.core.common.component.LineComponent import com.patrykandpatrick.vico.core.common.shape.Shape +import com.tangem.common.ui.charts.layer.TimeItemPlacer import com.tangem.common.ui.charts.layer.rememberMarketChartLayer -import com.tangem.common.ui.charts.marker.rememberTangemChartMarker +import com.tangem.common.ui.charts.layer.rememberTangemChartMarker import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider import com.tangem.common.ui.charts.state.* import com.tangem.core.ui.components.SpacerH16 @@ -50,6 +56,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toTimeFormat +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode @@ -73,70 +80,72 @@ fun MarketChart( splitChartSegmentColor: Color = TangemTheme.colors.icon.inactive, @FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float = 0.24f, @FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float = 0.24f, - noChartContent: @Composable BoxScope.() -> Unit, ) { var canvasWidth by remember { mutableIntStateOf(0) } - var canvasHeight by remember { mutableIntStateOf(0) } + var chartHeight by remember { mutableIntStateOf(0) } - val layer = rememberLayerFromState( - state = state, - splitChartSegmentColor = splitChartSegmentColor, - backgroundColorAlpha = backgroundColorAlpha, - backgroundSplitChartSegmentColorAlpha = backgroundSplitChartSegmentColorAlpha, - canvasHeight = canvasHeight, + val layer = rememberMarketChartLayer( + lineColor = state.chartColor, + backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha), + secondLineColor = splitChartSegmentColor, + backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha), + secondColorOnTheRightSide = state.markerHighlightRightSide.not(), + markerFraction = state.markerFraction, + axisValueOverrider = AxisValueOverrider.fixed(), + canvasHeight = chartHeight, ) + + val marker = rememberTangemChartMarker(color = state.chartColor) + val chart = rememberCartesianChart( layer, - startAxis = rememberMarketChartStartAxis( - yValueFormatter = state.yValueFormatter, - ), - bottomAxis = rememberMarketChartBottomAxis( - xValueFormatter = state.xValueFormatter, - ), + startAxis = rememberMarketChartStartAxis(state.yValueFormatter), + bottomAxis = rememberMarketChartBottomAxis(state.xValueFormatter), + horizontalLayout = HorizontalLayout.FullWidth(), + markerVisibilityListener = rememberMarketVisibilityListener(canvasWidth, state), + marker = marker, ) - val marker = rememberTangemChartMarker( - color = state.chartColor, - innerCircleColor = Color.White, - ) - val density = LocalDensity.current + + // we need to calculate what the overall height should be in order to get the correct height of the graph + val bottomAxisHeight = with(LocalDensity.current) { + TangemTheme.typography.caption2.fontSize.toPx().toInt() + TangemTheme.dimens.spacing26.toPx().toInt() + } CartesianChartHost( - modifier = modifier.onGloballyPositioned { - with(density) { + modifier = modifier + .onGloballyPositioned { canvasWidth = it.size.width - canvasHeight = if (it.size.height != 0) { - // FIXME get height bounded to min max chart points - it.size.height - 20.dp.toPx().toInt() - 27.dp.toPx().toInt() + chartHeight = if (it.size.height != 0) { + it.size.height - bottomAxisHeight } else { 0 } - } - }, + }, chart = chart, modelProducer = state.modelProducer, scrollState = rememberVicoScrollState(scrollEnabled = false), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - markerVisibilityListener = state.rememberMarketVisibilityListener(canvasWidth = canvasWidth), - diffAnimationSpec = null, - marker = marker, - placeholder = noChartContent, + animationSpec = null, ) } @Composable -private fun MarketChartState.rememberMarketVisibilityListener(canvasWidth: Int): CartesianMarkerVisibilityListener { - val state = this - return remember(state.markerVisibilityListener, canvasWidth) { +private fun rememberMarketVisibilityListener( + canvasWidth: Int, + state: MarketChartState, +): CartesianMarkerVisibilityListener { + val haptic = LocalHapticFeedback.current + return remember(state, canvasWidth) { val maxCanvasXFloat = canvasWidth.toFloat().takeIf { it != 0f } object : CartesianMarkerVisibilityListener { override fun onShown(marker: CartesianMarker, targets: List) { - state.stopDrawingAnimation() val xCanvas = (targets[0] as LineCartesianLayerMarkerTarget).canvasX state.markerFraction = maxCanvasXFloat?.let { xCanvas / it } state.markerVisibilityListener.onShown(marker, targets) + + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) } override fun onHidden(marker: CartesianMarker) { @@ -149,38 +158,29 @@ private fun MarketChartState.rememberMarketVisibilityListener(canvasWidth: Int): state.markerFraction = maxCanvasXFloat?.let { xCanvas / it } state.markerVisibilityListener.onUpdated(marker, targets) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) } } } } -@Composable -private fun rememberLayerFromState( - state: MarketChartState, - splitChartSegmentColor: Color, - @FloatRange(from = 0.0, to = 1.0) backgroundColorAlpha: Float, - @FloatRange(from = 0.0, to = 1.0) backgroundSplitChartSegmentColorAlpha: Float, - canvasHeight: Int, -): LineCartesianLayer { - return rememberMarketChartLayer( - lineColor = state.chartColor, - backgroundLineColor = state.chartColor.copy(alpha = backgroundColorAlpha), - secondLineColor = splitChartSegmentColor, - backgroundSecondLineColor = splitChartSegmentColor.copy(alpha = backgroundSplitChartSegmentColorAlpha), - secondColorOnTheRightSide = state.markerHighlightRightSide.not(), - startDrawingAnimation = state.startDrawingAnimationState, - markerFraction = state.markerFraction, - axisValueOverrider = AxisValueOverrider.adaptiveYValues(yFraction = 1.2f, round = true), // FIXME ? - canvasHeight = canvasHeight, - ) -} - @Composable private fun rememberMarketChartStartAxis( yValueFormatter: CartesianValueFormatter, ): VerticalAxis { + val textStyle = TangemTheme.typography.caption2 + val resolver = LocalFontFamilyResolver.current + val typeface by remember(resolver, textStyle) { + resolver.resolveAsTypeface( + fontFamily = textStyle.fontFamily, + fontWeight = textStyle.fontWeight ?: FontWeight.Normal, + fontStyle = textStyle.fontStyle ?: FontStyle.Normal, + fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All, + ) + } + return rememberCustomStartAxis( - axis = null, + line = null, tick = null, guideline = null, labelGuideline = rememberChartAxisGuidelineComponent( @@ -194,38 +194,44 @@ private fun rememberMarketChartStartAxis( end = TangemTheme.dimens.spacing4, ), textSize = TangemTheme.typography.caption2.fontSize, - typeface = TangemTheme.typography.caption2.toGraphicsTypeFace(), + typeface = typeface, ), horizontalLabelPosition = VerticalAxis.HorizontalLabelPosition.Inside, verticalLabelPosition = VerticalAxis.VerticalLabelPosition.Center, - itemPlacer = AxisItemPlacer.Vertical.count({ GUIDELINES_COUNT }, false), + itemPlacer = VerticalAxis.ItemPlacer.count({ GUIDELINES_COUNT }, false), valueFormatter = yValueFormatter, ) } @Composable -fun rememberMarketChartBottomAxis( +private fun rememberMarketChartBottomAxis( xValueFormatter: CartesianValueFormatter, ): HorizontalAxis { + val textStyle = TangemTheme.typography.caption2 + + val resolver = LocalFontFamilyResolver.current + + val typeface by remember(resolver, textStyle) { + resolver.resolveAsTypeface( + fontFamily = textStyle.fontFamily, + fontWeight = textStyle.fontWeight ?: FontWeight.Normal, + fontStyle = textStyle.fontStyle ?: FontStyle.Normal, + fontSynthesis = textStyle.fontSynthesis ?: FontSynthesis.All, + ) + } + return rememberBottomAxis( label = rememberAxisLabelComponent( color = TangemTheme.colors.text.tertiary, textSize = TangemTheme.typography.caption2.fontSize, - padding = Dimensions.of(top = TangemTheme.dimens.spacing20), - typeface = TangemTheme.typography.caption2.toGraphicsTypeFace(), + padding = Dimensions.of(top = TangemTheme.dimens.spacing26), + typeface = typeface, ), tick = null, - axis = null, + line = null, guideline = null, - sizeConstraint = BaseAxis.SizeConstraint.Exact(sizeDp = 37f), // FIXME ? - itemPlacer = remember { - AxisItemPlacer.Horizontal.default( - spacing = 25, // FIXME ? - offset = 60, // FIXME ? - shiftExtremeTicks = false, - addExtremeLabelPadding = false, - ) - }, + sizeConstraint = BaseAxis.SizeConstraint.Auto(), + itemPlacer = remember { TimeItemPlacer() }, valueFormatter = xValueFormatter, ) } @@ -245,19 +251,6 @@ private fun rememberChartAxisGuidelineComponent(color: Color): LineComponent { ) } -@Composable -internal fun TextStyle.toGraphicsTypeFace(): android.graphics.Typeface { - val resolver = LocalFontFamilyResolver.current - return remember(resolver, this) { - resolver.resolveAsTypeface( - fontFamily = this.fontFamily, - fontWeight = this.fontWeight ?: FontWeight.Normal, - fontStyle = this.fontStyle ?: FontStyle.Normal, - fontSynthesis = this.fontSynthesis ?: FontSynthesis.All, - ) - }.value -} - // region Preview @Suppress("LongMethod") @@ -275,7 +268,6 @@ private fun MarketChartPreview( chartLook = MarketChartLook( type = MarketChartLook.Type.Growing, markerHighlightRightSide = true, - animationOnDataChange = true, ) } } @@ -283,8 +275,8 @@ private fun MarketChartPreview( LaunchedEffect(key1 = Unit) { dataProducer.runTransactionSuspend { chartData = MarketChartData.Data( - x = x, - y = y, + x = x.toImmutableList(), + y = y.toImmutableList(), ) updateLook { it.copy( @@ -338,13 +330,9 @@ private fun MarketChartPreview( splitChartSegmentColor = TangemTheme.colors.icon.inactive, backgroundSplitChartSegmentColorAlpha = 0.24f, backgroundColorAlpha = 0.24f, - noChartContent = { }, ) SpacerH16() - Button(onClick = { chartState.startDrawingAnimation() }) { - Text("Start drawing animation") - } Button( onClick = { dataProducer.runTransaction { @@ -365,7 +353,7 @@ private fun MarketChartPreview( updateData { MarketChartData.Data( x = it.x, - y = it.y.reversed(), + y = it.y.reversed().toImmutableList(), ) } } @@ -374,15 +362,6 @@ private fun MarketChartPreview( ) { Text("Change Data") } - Button( - onClick = { - dataProducer.runTransaction { - updateLook { it.copy(animationOnDataChange = it.animationOnDataChange.not()) } - } - }, - ) { - Text("Change animationOnDataChange = ${look.animationOnDataChange}") - } Button( onClick = { 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 index d9902450d3..2a124ed8d2 100644 --- 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 @@ -12,19 +12,21 @@ 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.rememberLine 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.compose.common.shader.toDynamicShader 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.cartesian.layer.LineCartesianLayer 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 kotlinx.collections.immutable.toImmutableList import kotlin.random.Random @Composable @@ -44,18 +46,19 @@ fun MarketChartMini( MarketChartLook.Type.Falling -> fallingColor } - val lineSpec = rememberLineSpec( + val lineSpec = rememberLine( shader = ColorShader(lineColor.toArgb()), thickness = 1.dp, - backgroundShader = BrushShader( - brush = Brush.verticalGradient( - colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent), - ), - ), + backgroundShader = Brush.verticalGradient( + colors = listOf(lineColor.copy(alpha = 0.22f), Color.Transparent), + ).toDynamicShader(), ) - val layer = rememberLineCartesianLayer(listOf(lineSpec)) - val chart = rememberCartesianChart(layer) + val layer = rememberLineCartesianLayer(LineCartesianLayer.LineProvider.series(lineSpec)) + val chart = rememberCartesianChart( + layer, + horizontalLayout = HorizontalLayout.fullWidth(), + ) CartesianChartHost( modifier = modifier, @@ -63,7 +66,6 @@ fun MarketChartMini( model = model, zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), scrollState = rememberVicoScrollState(scrollEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), ) } @@ -74,8 +76,8 @@ fun MarketChartMini( @Composable private fun Preview() { val data = MarketChartRawData( - x = List(20) { Random.nextFloat() }, - y = List(20) { Random.nextFloat() }, + x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), ) TangemThemePreview { @@ -92,8 +94,8 @@ private fun Preview() { @Composable private fun PreviewColumn() { val data = MarketChartRawData( - x = List(20) { Random.nextFloat() }, - y = List(20) { Random.nextFloat() }, + x = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(20) { Random.nextFloat().toDouble() }.toImmutableList(), ) TangemThemePreview { diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt new file mode 100644 index 0000000000..59bf621547 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/downsample/LTThreeBuckets.kt @@ -0,0 +1,246 @@ +package com.tangem.common.ui.charts.downsample + +import kotlin.math.max + +/** + * ========================================================= + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= + * + * Downsamples the given data points to the desired number of buckets (points + 2). + * +[REDACTED_AUTHOR] + */ +object LTThreeBuckets { + + fun downsample(x: List, y: List, desiredBuckets: Int): Result { + require(x.size == y.size) { "X and Y must have the same size" } + require(desiredBuckets > 0) { "Desired buckets must be greater than 0" } + + val points = x.zip(y).mapIndexed { index, (x, y) -> Point(index, x, y) } + val results = mutableListOf() + + points.onPassBucketize(desiredBuckets) + .sliding(size = 3, step = 1) + .map { buckets -> Triangle.of(buckets) } + .fastForEach { triangle -> + if (results.isEmpty()) { + results.add(triangle.getFirst()) + } + + results.add(triangle.getResult()) + + if (results.size == desiredBuckets + 1) { + results.add(triangle.getLast()) + } + } + + val xRes = ArrayList(points.size) + val yRes = ArrayList(points.size) + val indexesRes = ArrayList(points.size) + + results.fastForEach { + xRes.add(it.x) + yRes.add(it.y) + indexesRes.add(it.originalIndex!!) + } + + return Result( + originalIndexes = indexesRes, + x = xRes, + y = yRes, + ) + } + + data class Result( + val originalIndexes: List, + val x: List, + val y: List, + ) +} + +private fun List.onPassBucketize(desiredBucketsCount: Int): List { + val middleSize = size - 2 + val bucketSize = middleSize / desiredBucketsCount + val remainingElements = middleSize % desiredBucketsCount + + require(bucketSize != 0) { + "Can't produce $desiredBucketsCount buckets from an input series of ${middleSize + 2} elements" + } + + val buckets = mutableListOf() + + // Add first point as the only point in the first bucket + buckets.add(Bucket.of(this[0])) + + var rest = this.subList(1, this.lastIndex) + + // Add middle buckets. + // When inputSize is not a multiple of desiredBuckets, + // remaining elements are equally distributed on the first buckets. + while (buckets.size < desiredBucketsCount + 1) { + val size = if (buckets.size <= remainingElements) bucketSize + 1 else bucketSize + buckets.add(Bucket.of(rest.subList(0, size))) + rest = rest.subList(size, rest.size) + } + + // Add last point as the only point in the last bucket + buckets.add(Bucket.of(this.last())) + + return buckets +} + +private fun List.sliding(size: Int, step: Int): List> { + val window = max(size, step) + val buffer = ArrayDeque() + var totalIn = 0 + + val lists = mutableListOf>() + + fastForEach { p -> + buffer.add(p) + ++totalIn + if (buffer.size == window) { + val batch = buffer.take(size) + lists.add(batch) + + repeat(step) { + buffer.removeFirst() + } + } + } + + if (buffer.isNotEmpty()) { + val totalOut = max(0, (totalIn + step - size - 1) / step) + 1 + if (totalOut > lists.size) { + val batch = buffer.take(size) + lists.add(batch) + } + } + + return lists +} + +private data class Point( + val originalIndex: Int? = null, + val x: Double, + val y: Double, +) + +private data class Bucket( + val data: List, + val center: Point, + val result: Point, + val first: Point, + val last: Point, +) { + companion object { + private fun centerBetweenPoints(a: Point, b: Point): Point { + val vector = Point( + x = b.x - a.x, + y = b.y - a.y, + ) + val halfVector = Point( + x = vector.x / 2, + y = vector.y / 2, + ) + + return Point( + x = a.x + halfVector.x, + y = a.y + halfVector.y, + ) + } + + fun of(points: List): Bucket { + val first = points.first() + val last = points.last() + + return Bucket( + data = points, + center = centerBetweenPoints(first, last), + result = first, + first = first, + last = last, + ) + } + + fun of(point: Point): Bucket { + return Bucket( + data = listOf(point), + center = point, + result = point, + first = point, + last = point, + ) + } + } +} + +private data class Triangle( + val left: Bucket, + val center: Bucket, + val right: Bucket, +) { + fun getResult(): Point { + return center.data.map { Area.ofTriangle(left.result, it, right.center) } + .maxByOrNull { it.value } + ?.generator + ?: error("Can't obtain max area triangle") + } + + fun getFirst(): Point { + return left.first + } + + fun getLast(): Point { + return right.last + } + + companion object { + fun of(buckets: List): Triangle { + return Triangle( + left = buckets[0], + center = buckets[1], + right = buckets[2], + ) + } + } +} + +private data class Area( + val generator: Point, + val value: Double, +) { + companion object { + fun ofTriangle(a: Point, b: Point, c: Point): Area { + val addends = listOf( + a.x * (b.y - c.y), + b.x * (c.y - a.y), + c.x * (a.y - b.y), + ) + val sum = addends.sum() + val value = kotlin.math.abs(sum / 2) + + return Area(b, value) + } + } +} + +inline fun List.fastForEach(action: (T) -> Unit) { + for (index in indices) { + val item = get(index) + action(item) + } +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt new file mode 100644 index 0000000000..0005e47d15 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/ChartMarker.kt @@ -0,0 +1,112 @@ +package com.tangem.common.ui.charts.layer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent +import com.patrykandpatrick.vico.compose.common.component.shapeComponent +import com.patrykandpatrick.vico.compose.common.of +import com.patrykandpatrick.vico.compose.common.shape.dashed +import com.patrykandpatrick.vico.core.cartesian.* +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker +import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerValueFormatter +import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker +import com.patrykandpatrick.vico.core.common.Dimensions +import com.patrykandpatrick.vico.core.common.LayeredComponent +import com.patrykandpatrick.vico.core.common.component.Component +import com.patrykandpatrick.vico.core.common.component.TextComponent +import com.patrykandpatrick.vico.core.common.shape.Shape +import com.tangem.core.ui.res.TangemTheme + +/** + * @param color The color of the indicator and guideline. + * @param innerCircleColor The color of the inner circle of the indicator. + * + * @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect. + */ +@Composable +internal fun rememberTangemChartMarker(color: Color): CartesianMarker { + val guideline = rememberUnboundedLineComponent( + color = color, + verticalAddDrawSpace = TangemTheme.dimens.spacing24, + shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) }, + ) + + return remember(guideline) { + val outColor = guideline.color + + object : DefaultCartesianMarker( + label = TextComponent(textSizeSp = 0f), + indicator = ::indicator, + indicatorSizeDp = INDICATOR_SIZE_DP, + guideline = guideline, + valueFormatter = object : CartesianMarkerValueFormatter { + override fun format( + context: CartesianDrawContext, + targets: List, + ): CharSequence = "" + }, + ) { + override fun updateInsets( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + model: CartesianChartModel, + insets: Insets, + ) { + with(context) { + super.updateInsets(context, horizontalDimensions, model, insets) + val baseShadowInsetDp = + CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP + val topInset = (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels + val bottomInset = (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels + insets.ensureValuesAtLeast(top = topInset, bottom = bottomInset) + } + } + + override fun CartesianDrawContext.drawIndicator(x: Float, y: Float, color: Int, halfIndicatorSize: Float) { + val indicator = indicator ?: return + cacheStore + .getOrSet(keyNamespace, indicator, outColor) { indicator.invoke(outColor) } + .draw( + this, + x - halfIndicatorSize, + y - halfIndicatorSize, + x + halfIndicatorSize, + y + halfIndicatorSize, + ) + } + } + } +} + +private fun indicator(color: Int): Component { + val composeColor = Color(color) + + return LayeredComponent( + rear = shapeComponent( + color = composeColor.copy(alpha = INDICATOR_REAR_COLOR_ALPHA), + shape = Shape.Pill, + ), + front = LayeredComponent( + rear = shapeComponent( + color = composeColor, + shape = Shape.Pill, + ), + front = shapeComponent( + color = Color.White, + shape = Shape.Pill, + ), + padding = indicatorPadding, + ), + padding = indicatorPadding, + ) +} + +private val indicatorPadding = Dimensions.of(3.dp) +private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f +private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f +private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f +private const val INDICATOR_SIZE_DP = 16f +private const val INDICATOR_REAR_COLOR_ALPHA = .24f \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt index b224a1838d..a01ceb3d22 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/MarketChartLayer.kt @@ -2,9 +2,6 @@ package com.tangem.common.ui.charts.layer import android.content.res.Configuration import androidx.annotation.FloatRange -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,22 +17,22 @@ import androidx.compose.ui.unit.dp import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost import com.patrykandpatrick.vico.compose.cartesian.fullWidth import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLineSpec +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberSplitLine import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState -import com.patrykandpatrick.vico.compose.common.shader.BrushShader +import com.patrykandpatrick.vico.compose.common.shader.toDynamicShader import com.patrykandpatrick.vico.core.cartesian.HorizontalLayout import com.patrykandpatrick.vico.core.cartesian.Zoom import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.core.common.shader.ColorShader import com.patrykandpatrick.vico.core.common.shader.DynamicShader import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal /** @@ -59,117 +56,50 @@ internal fun rememberMarketChartLayer( backgroundLineColor: Color, secondLineColor: Color, backgroundSecondLineColor: Color, - startDrawingAnimation: MutableState, axisValueOverrider: AxisValueOverrider, secondColorOnTheRightSide: Boolean, @FloatRange(from = 0.0, to = 1.0) markerFraction: Float?, canvasHeight: Int, ): LineCartesianLayer { - var animationFraction: Float? by remember { mutableStateOf(null) } + val backgroundColorLineGradient = persistentListOf(backgroundLineColor, Color.Transparent) + val backgroundSecondLineColorGradient = persistentListOf(backgroundSecondLineColor, Color.Transparent) - LaunchedEffect(startDrawingAnimation.value) { - animationFraction = null - if (startDrawingAnimation.value) { - animate( - initialValue = 0f, - targetValue = 1f, - animationSpec = tween(easing = LinearEasing, durationMillis = 1000), - ) { start, _ -> - if (start == 1f) { - animationFraction = null - startDrawingAnimation.value = false - } else { - animationFraction = start - } - } - } - } + val markerSet = markerFraction != null - return rememberRawMarketChartLayer( - lineColor = lineColor, - backgroundLineColor = backgroundLineColor, - secondLineColor = secondLineColor, - backgroundSecondLineColor = backgroundSecondLineColor, + return rememberLayer( + fractionValue = markerFraction ?: 0f, axisValueOverrider = axisValueOverrider, - secondColorOnTheRightSide = secondColorOnTheRightSide, - markerFraction = markerFraction, - animationFraction = animationFraction, canvasHeight = canvasHeight, + lineColor = if (markerFraction != null) { + secondLineColor + } else { + lineColor + }, + backLineColor = if (markerSet && !secondColorOnTheRightSide) { + backgroundSecondLineColorGradient + } else { + backgroundColorLineGradient + }, + lineColorRight = when { + markerSet && secondColorOnTheRightSide -> secondLineColor + else -> lineColor + }, + backLineColorRight = when { + markerSet && secondColorOnTheRightSide -> backgroundSecondLineColorGradient + else -> backgroundColorLineGradient + }, ) } @Suppress("LongParameterList") -@Composable -private fun rememberRawMarketChartLayer( - lineColor: Color, - backgroundLineColor: Color, - secondLineColor: Color, - backgroundSecondLineColor: Color, - axisValueOverrider: AxisValueOverrider, - canvasHeight: Int, - secondColorOnTheRightSide: Boolean = false, - @FloatRange(from = 0.0, to = 1.0) markerFraction: Float? = null, - @FloatRange(from = 0.0, to = 1.0) animationFraction: Float? = null, -): LineCartesianLayer { - val backgroundColorLineGradient = listOf(backgroundLineColor, Color.Transparent) - val backgroundSecondLineColorGradient = listOf(backgroundSecondLineColor, Color.Transparent) - - val markerSet = markerFraction != null - val animationRunning = animationFraction != null && animationFraction != 1f - - val layerColors = when { - !animationRunning && markerSet && secondColorOnTheRightSide -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - lineColorRight = secondLineColor, - backLineColorRight = backgroundSecondLineColorGradient, - ) - } - !animationRunning && markerSet && !secondColorOnTheRightSide -> { - LayerColors( - lineColor = secondLineColor, - backLineColor = backgroundSecondLineColorGradient, - lineColorRight = lineColor, - backLineColorRight = backgroundColorLineGradient, - ) - } - animationRunning -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - lineColorRight = Color.Transparent, - backLineColorRight = listOf(Color.Transparent, Color.Transparent), - ) - } - else -> { - LayerColors( - lineColor = lineColor, - backLineColor = backgroundColorLineGradient, - ) - } - } - - return rememberLayer( - fractionValue = animationFraction ?: markerFraction, - axisValueOverrider = axisValueOverrider, - layerColors = layerColors, - canvasHeight = canvasHeight, - ) -} - -private data class LayerColors( - val lineColor: Color, - val backLineColor: List, - val lineColorRight: Color? = null, - val backLineColorRight: List? = null, -) - @Composable private fun rememberLayer( - fractionValue: Float?, + fractionValue: Float, axisValueOverrider: AxisValueOverrider, - layerColors: LayerColors, + lineColor: Color, + backLineColor: ImmutableList, + lineColorRight: Color, + backLineColorRight: ImmutableList, canvasHeight: Int, ): LineCartesianLayer { val endGradientColorPosition = if (canvasHeight != 0) { @@ -178,47 +108,27 @@ private fun rememberLayer( Float.POSITIVE_INFINITY } + val alineColor = remember(lineColor) { lineColor.toArgb() } + val alineColorRight = remember(lineColorRight) { lineColorRight.toArgb() } + return rememberLineCartesianLayer( - listOf( - if (layerColors.lineColorRight == null || layerColors.backLineColorRight == null || fractionValue == null) { - rememberLineSpec( - shader = remember(layerColors.lineColor) { ColorShader(color = layerColors.lineColor.toArgb()) }, - backgroundShader = remember(layerColors.backLineColor, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColor, - endY = endGradientColorPosition, - ), - ) - }, - ) - } else { - rememberSplitLineSpec( - shader = remember(layerColors.lineColor, layerColors.lineColorRight, fractionValue) { - DynamicShader.Companion.horizontalGradient( - colors = intArrayOf(layerColors.lineColor.toArgb(), layerColors.lineColorRight.toArgb()), - positions = floatArrayOf(fractionValue, fractionValue), - ) - }, - backgroundShaderFirst = remember(layerColors.backLineColor, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColor, - endY = endGradientColorPosition, - ), - ) - }, - backgroundShaderSecond = remember(layerColors.backLineColorRight, endGradientColorPosition) { - BrushShader( - brush = Brush.verticalGradient( - colors = layerColors.backLineColorRight, - endY = endGradientColorPosition, - ), - ) - }, - xSplitFraction = fractionValue, - ) - }, + LineCartesianLayer.LineProvider.series( + rememberSplitLine( + shader = DynamicShader.Companion.horizontalGradient( + colors = intArrayOf(alineColor, alineColorRight), + positions = floatArrayOf(fractionValue, fractionValue), + ), + backgroundShaderFirst = Brush.verticalGradient( + colors = backLineColor, + endY = endGradientColorPosition, + ).toDynamicShader(), + backgroundShaderSecond = Brush.verticalGradient( + colors = backLineColorRight, + endY = endGradientColorPosition, + ).toDynamicShader(), + xSplitFraction = fractionValue, + thickness = 1.dp, + ), ), axisValueOverrider = axisValueOverrider, ) @@ -250,25 +160,26 @@ private fun LayerChartPreview( CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, backgroundSecondLineColor = Color.Gray.copy(alpha = 0.24f), secondColorOnTheRightSide = true, axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), + markerFraction = 0.35f, canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, @@ -278,16 +189,16 @@ private fun LayerChartPreview( axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) CartesianChartHost( modifier = Modifier.fillMaxWidth(), chart = rememberCartesianChart( - rememberRawMarketChartLayer( + rememberMarketChartLayer( lineColor = lineColor, backgroundLineColor = lineColor.copy(alpha = 0.24f), secondLineColor = Color.Gray, @@ -297,29 +208,9 @@ private fun LayerChartPreview( axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), canvasHeight = 495, ), + horizontalLayout = HorizontalLayout.fullWidth(), ), zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - model = model, - ) - - CartesianChartHost( - modifier = Modifier.fillMaxWidth(), - chart = rememberCartesianChart( - rememberRawMarketChartLayer( - lineColor = lineColor, - backgroundLineColor = lineColor.copy(alpha = 0.24f), - secondLineColor = lineColor, - backgroundSecondLineColor = lineColor.copy(alpha = 0.24f), - markerFraction = 0.35f, - secondColorOnTheRightSide = true, - animationFraction = 0.7f, - axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), - canvasHeight = 495, - ), - ), - zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), model = model, ) } diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt new file mode 100644 index 0000000000..034af69926 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/layer/TimeItemPlacer.kt @@ -0,0 +1,56 @@ +package com.tangem.common.ui.charts.layer + +import com.patrykandpatrick.vico.core.cartesian.CartesianDrawContext +import com.patrykandpatrick.vico.core.cartesian.CartesianMeasureContext +import com.patrykandpatrick.vico.core.cartesian.HorizontalDimensions +import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.core.cartesian.data.ChartValues + +@Suppress("MagicNumber") +class TimeItemPlacer : HorizontalAxis.ItemPlacer { + + private val ChartValues.measuredLabelValues + get() = buildList { + // produce exactly 6 values distributed evenly + val xLength = maxX - minX + val xStep = xLength / 7 + + repeat(times = 6) { + add(minX + xStep * (it + 1)) + } + } + + override fun getEndHorizontalAxisInset( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + tickThickness: Float, + maxLabelWidth: Float, + ): Float = 0f + + override fun getStartHorizontalAxisInset( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + tickThickness: Float, + maxLabelWidth: Float, + ): Float = 0f + + override fun getHeightMeasurementLabelValues( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + fullXRange: ClosedFloatingPointRange, + maxLabelWidth: Float, + ): List = context.chartValues.measuredLabelValues + + override fun getLabelValues( + context: CartesianDrawContext, + visibleXRange: ClosedFloatingPointRange, + fullXRange: ClosedFloatingPointRange, + maxLabelWidth: Float, + ): List = context.chartValues.measuredLabelValues + + override fun getWidthMeasurementLabelValues( + context: CartesianMeasureContext, + horizontalDimensions: HorizontalDimensions, + fullXRange: ClosedFloatingPointRange, + ): List = context.chartValues.measuredLabelValues +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt deleted file mode 100644 index e27ce63a70..0000000000 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/marker/ChartMarker.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.tangem.common.ui.charts.marker - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost -import com.patrykandpatrick.vico.compose.cartesian.fullWidth -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineSpec -import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart -import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState -import com.patrykandpatrick.vico.compose.common.component.rememberLayeredComponent -import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent -import com.patrykandpatrick.vico.compose.common.component.rememberUnboundedLineComponent -import com.patrykandpatrick.vico.compose.common.of -import com.patrykandpatrick.vico.compose.common.shader.color -import com.patrykandpatrick.vico.compose.common.shape.dashed -import com.patrykandpatrick.vico.core.cartesian.* -import com.patrykandpatrick.vico.core.cartesian.data.AxisValueOverrider -import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel -import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel -import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker -import com.patrykandpatrick.vico.core.cartesian.marker.DefaultCartesianMarker -import com.patrykandpatrick.vico.core.common.Dimensions -import com.patrykandpatrick.vico.core.common.component.TextComponent -import com.patrykandpatrick.vico.core.common.shader.DynamicShader -import com.patrykandpatrick.vico.core.common.shape.Shape -import com.tangem.common.ui.charts.preview.MarketChartPreviewDataProvider -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import java.math.BigDecimal - -/** - * @param color The color of the indicator and guideline. - * @param innerCircleColor The color of the inner circle of the indicator. - * - * @return A [CartesianMarker] that consists of a dashed guideline and a layered indicator with a shadow effect. - */ -@Composable -internal fun rememberTangemChartMarker(color: Color, innerCircleColor: Color): CartesianMarker { - val indicatorFrontComponent = rememberShapeComponent( - shape = Shape.Pill, - color = innerCircleColor, - ) - val indicatorCenterComponent = rememberShapeComponent( - shape = Shape.Pill, - color = color, - ) - val indicatorRearComponent = rememberShapeComponent( - shape = Shape.Pill, - color = if (color == Color.Transparent) { - Color.Transparent - } else { - color.copy(alpha = INDICATOR_REAR_COLOR_ALPHA) - }, - ) - val indicator = rememberLayeredComponent( - rear = indicatorRearComponent, - front = rememberLayeredComponent( - rear = indicatorCenterComponent, - front = indicatorFrontComponent, - padding = indicatorPadding, - ), - padding = indicatorPadding, - ) - val guideline = rememberUnboundedLineComponent( - color = color, - verticalAddDrawSpace = TangemTheme.dimens.spacing24, - shape = remember { Shape.dashed(Shape.Rectangle, 4.dp, 4.dp) }, - ) - return remember(indicator, guideline) { - object : DefaultCartesianMarker( - label = TextComponent.build { textSizeSp = 0f }, - indicator = indicator, - indicatorSizeDp = INDICATOR_SIZE_DP, - guideline = guideline, - ) { - override fun getInsets( - context: CartesianMeasureContext, - outInsets: Insets, - horizontalDimensions: HorizontalDimensions, - ) { - with(context) { - super.getInsets(context, outInsets, horizontalDimensions) - val baseShadowInsetDp = - CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER * LABEL_BACKGROUND_SHADOW_RADIUS_DP - outInsets.top += (baseShadowInsetDp - LABEL_BACKGROUND_SHADOW_DY_DP).pixels - outInsets.bottom += (baseShadowInsetDp + LABEL_BACKGROUND_SHADOW_DY_DP).pixels - } - } - } - } -} - -private val indicatorPadding = Dimensions.of(3.dp) -private const val LABEL_BACKGROUND_SHADOW_RADIUS_DP = 4f -private const val LABEL_BACKGROUND_SHADOW_DY_DP = 2f -private const val CLIPPING_FREE_SHADOW_RADIUS_MULTIPLIER = 1.4f -private const val INDICATOR_SIZE_DP = 16f -private const val INDICATOR_REAR_COLOR_ALPHA = .24f - -// region Preview - -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemChartMarkerPreview( - @PreviewParameter(MarketChartPreviewDataProvider::class) previewData: Pair, List>, -) { - val marker = rememberTangemChartMarker(Color.Red, Color.White) - val y = previewData.second.map { it.toFloat() } - val x = List(y.size) { it.toFloat() } - val model = CartesianChartModel(LineCartesianLayerModel.build { series(x, y) }) - - val centerAprx = (model.models[0].minX + model.models[0].maxX) / 2f - val center = model.models[0].getXDeltaGcd().let { centerAprx - centerAprx % it } - - TangemThemePreview { - Box(modifier = Modifier.background(TangemTheme.colors.background.primary)) { - CartesianChartHost( - modifier = Modifier.fillMaxWidth(), - chart = rememberCartesianChart( - rememberLineCartesianLayer( - listOf(rememberLineSpec(shader = DynamicShader.color(Color.Blue))), - axisValueOverrider = AxisValueOverrider.fixed(minY = model.models[0].minY), - ), - persistentMarkers = mapOf(center to marker), - ), - model = model, - marker = marker, - zoomState = rememberVicoZoomState(initialZoom = Zoom.Content, zoomEnabled = false), - horizontalLayout = HorizontalLayout.fullWidth(), - ) - } - } -} - -// endregion Preview \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt index 7e4887fedc..44f973dc73 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartData.kt @@ -1,6 +1,8 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal @Immutable @@ -30,7 +32,7 @@ sealed interface MarketChartData { */ @Immutable data class Data( - val x: List = listOf(), - val y: List = listOf(), + val x: ImmutableList = persistentListOf(), + val y: ImmutableList = persistentListOf(), ) : MarketChartData } \ 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 bcad7d3ceb..86f5ef5dd6 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 @@ -3,13 +3,13 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Stable import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel -import com.patrykandpatrick.vico.core.common.data.ExtraStore -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import com.tangem.common.ui.charts.state.converter.PointValuesConverter +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.formatter.FormatterWrapWithCache +import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.withContext -import java.math.BigDecimal +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * This class represents a transaction for updating the state and look of a Market Chart. @@ -25,7 +25,12 @@ class Transaction( var chartData: MarketChartData.NoData? = null fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) { - chartLook = block(currentLook) + val newLook = block(currentLook) + + chartLook = newLook.copy( + xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter), + yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter), + ) } fun updateState(block: (prev: MarketChartData) -> MarketChartData.NoData) { @@ -56,7 +61,12 @@ class TransactionSuspend( } fun updateLook(block: (prev: MarketChartLook) -> MarketChartLook) { - chartLook = block(currentLook) + val newLook = block(currentLook) + + chartLook = newLook.copy( + xAxisFormatter = FormatterWrapWithCache(newLook.xAxisFormatter), + yAxisFormatter = FormatterWrapWithCache(newLook.yAxisFormatter), + ) } internal fun updateState(block: (prev: MarketChartData) -> MarketChartData) { @@ -75,21 +85,24 @@ class TransactionSuspend( class MarketChartDataProducer private constructor( initialData: MarketChartData, initialLook: MarketChartLook, - val pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + val pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), private val dispatcher: CoroutineDispatcher = Dispatchers.Default, ) { - internal val startDrawingAnimation = MutableSharedFlow() internal val dataState = MutableStateFlow(initialData) internal val lookState = MutableStateFlow(initialLook) internal val entries = MutableStateFlow>(emptyList()) - - internal val modelProducer = CartesianChartModelProducer.build(dispatcher = dispatcher) + internal val modelProducer = CartesianChartModelProducer(dispatcher = dispatcher) + internal val rawData = MutableStateFlow(null) + private val mutex = Mutex() /** * This function runs a suspending transaction block to update the state and look of the Market Chart. */ - suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) = - handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block)) + suspend fun runTransactionSuspend(block: TransactionSuspend.() -> Unit) = withContext(dispatcher) { + mutex.withLock { + handleTransactionSuspend(transaction = TransactionSuspend(dataState.value, lookState.value).apply(block)) + } + } /** * This function runs a non-suspending transaction block to update the state and look of the Market Chart. @@ -102,32 +115,30 @@ class MarketChartDataProducer private constructor( val chartData = transaction.chartData val oldData = dataState.value - if (chartData != null) { - dataState.value = chartData - } - if (chartData is MarketChartData.Data && (oldData !is MarketChartData.Data || oldData != chartData)) { - if (lookState.value.animationOnDataChange) { - startDrawingAnimation.emit(Unit) - } - withContext(dispatcher) { - val rawData = pointsValuesConverter.convert(chartData) + (lookState.value.xAxisFormatter as? FormatterWrapWithCache)?.clearCache() + (lookState.value.yAxisFormatter as? FormatterWrapWithCache)?.clearCache() - val entriesLocal = - rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) } + val rawData = pointsValuesConverter.convert(chartData) - entries.value = entriesLocal + val entriesLocal = + rawData.x.mapIndexed { index, fl -> LineCartesianLayerModel.Entry(fl, rawData.y[index]) } + currentCoroutineContext().ensureActive() + + runCatching { modelProducer.runTransaction { add(LineCartesianLayerModel.Partial(series = listOf(entriesLocal))) - - updateExtras { - it[entriesKey] = entriesLocal - it[xKey] = chartData.x - it[yKey] = chartData.y - } - }.await() + } } + + entries.value = entriesLocal + dataState.value = chartData + this.rawData.value = rawData + + delay(timeMillis = 200) + } else if (chartData != null) { + dataState.value = chartData } nonSuspendTransaction?.let { handleTransaction(it) } @@ -143,10 +154,6 @@ class MarketChartDataProducer private constructor( } companion object { - internal val entriesKey = ExtraStore.Key>() - internal val xKey = ExtraStore.Key>() - internal val yKey = ExtraStore.Key>() - private val initialData: MarketChartData = MarketChartData.NoData.Empty private val initialLook: MarketChartLook = MarketChartLook() @@ -159,7 +166,7 @@ class MarketChartDataProducer private constructor( * @return A MarketChartDataProducer. */ suspend fun buildSuspend( - pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), dispatcher: CoroutineDispatcher = Dispatchers.Default, block: TransactionSuspend.() -> Unit, ): MarketChartDataProducer { @@ -184,7 +191,7 @@ class MarketChartDataProducer private constructor( * @return A MarketChartDataProducer. */ fun build( - pointsValuesConverter: PointValuesConverter = DefaultPointValuesConverter, + pointsValuesConverter: PointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = true), dispatcher: CoroutineDispatcher = Dispatchers.Default, block: Transaction.() -> Unit, ): MarketChartDataProducer { diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt index c9ecaae2c0..dc194c3b3b 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/MarketChartLook.kt @@ -1,5 +1,8 @@ package com.tangem.common.ui.charts.state +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.charts.state.formatter.AxisLabelFormatter + /** * This class represents the look and feel of a Market Chart. * It includes properties for type, marker highlight, animation on data change, animate data appearance, @@ -7,16 +10,13 @@ package com.tangem.common.ui.charts.state * * @property type The type of the chart, can be either Growing or Falling. * @property markerHighlightRightSide A boolean indicating whether the marker highlights the right side of the chart. - * @property animationOnDataChange A boolean indicating whether to animate on data change. - * @property animateDataAppearance A boolean indicating whether to animate data appearance. * @property xAxisFormatter A formatter for the x-axis labels. * @property yAxisFormatter A formatter for the y-axis labels. */ +@Immutable data class MarketChartLook( val type: Type = Type.Growing, val markerHighlightRightSide: Boolean = true, - val animationOnDataChange: Boolean = false, - val animateDataAppearance: Boolean = false, val xAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() }, val yAxisFormatter: AxisLabelFormatter = AxisLabelFormatter { it.toString() }, ) { 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 index a1b7e91f2c..df6d124201 100644 --- 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 @@ -1,9 +1,20 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +/** + * This class represents raw data for a Market Chart. Used for drawing the chart. + * + * @property originalIndexes If the source data has the original representation (due to reduced sampling), + * this list contains the original indexes of the data points. + * @property y The list of y-values. + * @property x The list of x-values. + */ @Immutable data class MarketChartRawData( - val y: List, - val x: List = List(y.size) { 1f }, + val originalIndexes: ImmutableList? = null, + val y: ImmutableList, + val x: ImmutableList = List(y.size) { 1.0 }.toImmutableList(), ) \ 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 92a0379f5d..8db48deab6 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 @@ -2,7 +2,6 @@ package com.tangem.common.ui.charts.state import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarker import com.patrykandpatrick.vico.core.cartesian.marker.CartesianMarkerVisibilityListener @@ -20,26 +19,22 @@ import java.math.BigDecimal @Composable fun rememberMarketChartState( dataProducer: MarketChartDataProducer = remember { MarketChartDataProducer.build {} }, - colorMapper: (MarketChartLook.Type) -> Color = { - when (it) { - MarketChartLook.Type.Growing -> Color.Green - MarketChartLook.Type.Falling -> Color.Red + colorMapper: (MarketChartLook.Type) -> Color = remember { + { + when (it) { + MarketChartLook.Type.Growing -> Color.Green + MarketChartLook.Type.Falling -> Color.Red + } } }, onMarkerShown: (x: BigDecimal?, y: BigDecimal?) -> Unit = { _, _ -> }, ): MarketChartState { - val lookState = dataProducer.lookState.collectAsStateWithLifecycle() + val lookState = dataProducer.lookState.collectAsState() val state = remember(dataProducer, lookState, colorMapper, onMarkerShown) { MarketChartState(dataProducer, lookState, colorMapper, onMarkerShown) } - LaunchedEffect(Unit) { - dataProducer.startDrawingAnimation.collect { - state.startDrawingAnimation() - } - } - return state } @@ -59,7 +54,6 @@ class MarketChartState internal constructor( private val colorMapper: (MarketChartLook.Type) -> Color, private val markerCallback: (x: BigDecimal?, y: BigDecimal?) -> Unit, ) { - internal val startDrawingAnimationState = mutableStateOf(false) internal val modelProducer = dataProducer.modelProducer internal val chartColor by derivedStateOf { @@ -70,29 +64,29 @@ class MarketChartState internal constructor( lookState.value.markerHighlightRightSide } - internal val xValueFormatter by derivedStateOf { - CartesianValueFormatter { value, _, _ -> - val state = dataProducer.dataState.value as? MarketChartData.Data - ?: return@CartesianValueFormatter value.toString() + internal val xValueFormatter = CartesianValueFormatter { value, _, _ -> + val formatter = dataProducer.lookState.value.xAxisFormatter - lookState.value.xAxisFormatter.format( - value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state), - ) - } + val state = dataProducer.dataState.value as? MarketChartData.Data + ?: return@CartesianValueFormatter value.toString() + + formatter.format( + value = dataProducer.pointsValuesConverter.prepareRawXForFormat(value, state), + ) } - internal val yValueFormatter by derivedStateOf { - CartesianValueFormatter { value, _, _ -> - val state = dataProducer.dataState.value as? MarketChartData.Data - ?: return@CartesianValueFormatter value.toString() + internal val yValueFormatter = CartesianValueFormatter { value, _, _ -> + val formatter = dataProducer.lookState.value.yAxisFormatter - lookState.value.yAxisFormatter.format( - value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state), - ) - } + val state = dataProducer.dataState.value as? MarketChartData.Data + ?: return@CartesianValueFormatter value.toString() + + formatter.format( + value = dataProducer.pointsValuesConverter.prepareRawYForFormat(value, state), + ) } - internal var markerFraction: Float? by mutableStateOf(null) + internal var markerFraction by mutableStateOf(null) internal val markerVisibilityListener = object : CartesianMarkerVisibilityListener { override fun onShown(marker: CartesianMarker, targets: List) { @@ -116,24 +110,17 @@ class MarketChartState internal constructor( } } - val isDrawingAnimationInProgress: Boolean by derivedStateOf { - startDrawingAnimationState.value - } - private fun getPoint(targets: List): Pair? { val entry = (targets[0] as LineCartesianLayerMarkerTarget).points[0].entry val entryIndex = dataProducer.entries.value.indexOf(entry).takeIf { it != -1 } ?: return null val state = dataProducer.dataState.value as? MarketChartData.Data ?: return null - val x = state.x.getOrNull(entryIndex) ?: return null - val y = state.y.getOrNull(entryIndex) ?: return null + val rawData = dataProducer.rawData.value ?: return null + + val originalIndex = rawData.originalIndexes?.getOrNull(entryIndex) + val index = originalIndex ?: entryIndex + + val x = state.x.getOrNull(index) ?: return null + val y = state.y.getOrNull(index) ?: return null return x to y } - - fun startDrawingAnimation() { - startDrawingAnimationState.value = true - } - - fun stopDrawingAnimation() { - startDrawingAnimationState.value = false - } } \ No newline at end of file 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 deleted file mode 100644 index 1dab1cd0cc..0000000000 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/PointValuesConverter.kt +++ /dev/null @@ -1,69 +0,0 @@ -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/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt new file mode 100644 index 0000000000..107eef4c68 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PointValuesConverter.kt @@ -0,0 +1,23 @@ +package com.tangem.common.ui.charts.state.converter + +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +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. + * + * **[prepareRawXForFormat] and [prepareRawYForFormat] must be very fast because they are called in the onDraw method** + */ +interface PointValuesConverter { + + fun convert(data: MarketChartData.Data): MarketChartRawData + + fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal + + fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt new file mode 100644 index 0000000000..e51a8f72a6 --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/converter/PriceAndTimePointValuesConverter.kt @@ -0,0 +1,108 @@ +package com.tangem.common.ui.charts.state.converter + +import com.tangem.common.ui.charts.downsample.LTThreeBuckets +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.MarketChartRawData +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +@Suppress("MagicNumber") +class PriceAndTimePointValuesConverter( + private val needToFormatAxis: Boolean, +) : PointValuesConverter { + + private data class MinMaxCache( + val minX: BigDecimal, + val maxX: BigDecimal, + val minY: BigDecimal, + val maxY: BigDecimal, + ) + + private var minMaxCache = MinMaxCache(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO) + private val formatYValuesCache = mutableMapOf() + private val formatXValuesCache = mutableMapOf() + + override fun convert(data: MarketChartData.Data): MarketChartRawData { + formatYValuesCache.clear() + formatXValuesCache.clear() + val cache = MinMaxCache( + minY = data.y.minOrNull() ?: BigDecimal.ZERO, + maxY = data.y.maxOrNull() ?: BigDecimal.ZERO, + minX = data.x.minOrNull() ?: BigDecimal.ZERO, + maxX = data.x.maxOrNull() ?: BigDecimal.ZERO, + ) + minMaxCache = cache + + val normY = data.y.normalizeToDouble(min = cache.minY, max = cache.maxY) + val normX = data.x.normalizeTime(min = cache.minX, max = cache.maxX) + + return if (normX.size > MAX_POINTS) { + LTThreeBuckets + .downsample(normX, normY, MAX_POINTS - 2) + .let { + MarketChartRawData( + originalIndexes = it.originalIndexes.toImmutableList(), + x = it.x.toImmutableList(), + y = it.y.toImmutableList(), + ) + } + } else { + MarketChartRawData( + x = normX.toImmutableList(), + y = normY.toImmutableList(), + ) + } + } + + override fun prepareRawXForFormat(rawX: Double, data: MarketChartData.Data): BigDecimal { + if (!needToFormatAxis) return BigDecimal.ZERO + if (formatXValuesCache.containsKey(rawX)) return formatXValuesCache[rawX]!! + + val result = (rawX * MINUTE).toBigDecimal() + + formatXValuesCache[rawX] = result + return result + } + + override fun prepareRawYForFormat(rawY: Double, data: MarketChartData.Data): BigDecimal { + if (!needToFormatAxis) return BigDecimal.ZERO + if (formatYValuesCache.containsKey(rawY)) return formatYValuesCache[rawY]!! + + val min = minMaxCache.minY + val max = minMaxCache.maxY + val length = max - min + + val result = when { + rawY < 0.01f -> min + rawY < 0.55f && rawY > 0.45f -> min + length / 2.toBigDecimal() + rawY > 0.97f && rawY < 1.01f -> max + else -> length * rawY.toBigDecimal() + min + } + formatYValuesCache[rawY] = result + return result + } + + private fun List.normalizeToDouble(min: BigDecimal, max: BigDecimal): List { + if (min == max) { + return List(size) { 0.5 } + } + + return map { ((it - min) / (max - min)).toDouble() } + } + + private fun List.normalizeTime(min: BigDecimal, max: BigDecimal): List { + if (min == max) { + return List(size) { 0.5 } + } + + return map { + (it / MINUTE_BIG).toDouble() + } + } + + private companion object { + private const val MAX_POINTS = 502 + private const val MINUTE = 60000L + private val MINUTE_BIG = 60000L.toBigDecimal() + } +} \ No newline at end of file diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt similarity index 76% rename from common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt rename to common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt index e01681994c..46feae32c2 100644 --- a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/AxisLabelFormatter.kt +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/AxisLabelFormatter.kt @@ -1,4 +1,4 @@ -package com.tangem.common.ui.charts.state +package com.tangem.common.ui.charts.state.formatter import androidx.compose.runtime.Stable import java.math.BigDecimal @@ -7,6 +7,8 @@ import java.math.BigDecimal * Used for formatting the axis labels in a chart. * It takes a BigDecimal value and returns a CharSequence that represents the formatted label. * + * [format] has to be very fast because it is called in the onDraw method. + * * @param value The value to be formatted. * @return The formatted label as a CharSequence. */ diff --git a/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt new file mode 100644 index 0000000000..b0a0fbe8bc --- /dev/null +++ b/common/ui-charts/src/main/kotlin/com/tangem/common/ui/charts/state/formatter/FormatterWrapWithCache.kt @@ -0,0 +1,15 @@ +package com.tangem.common.ui.charts.state.formatter + +import java.math.BigDecimal + +internal class FormatterWrapWithCache(private val formatter: AxisLabelFormatter) : AxisLabelFormatter { + private val cache = mutableMapOf() + + override fun format(value: BigDecimal): CharSequence { + return cache.getOrPut(value) { formatter.format(value) } + } + + fun clearCache() { + cache.clear() + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt index 014f9adf75..d09e293151 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/markets/TangemTechMarketsApi.kt @@ -28,6 +28,7 @@ interface TangemTechMarketsApi { @GET("coins/{coin_id}/history") suspend fun getCoinChart( + @Path("coin_id") coinId: String, @Query("currency") currency: String, @Query("interval") interval: String, ): ApiResponse diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index 300231f9c3..f62345873b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -56,6 +56,7 @@ fun TangemTheme( ) { CompositionLocalProvider( LocalTangemShimmer provides TangemShimmer, + LocalMainBottomSheetColor provides remember { mutableStateOf(Color.Unspecified) }, ) { ProvideTextStyle( value = TangemTheme.typography.body1, @@ -246,4 +247,8 @@ val LocalWindowSize = staticCompositionLocalOf { val LocalTangemShimmer = staticCompositionLocalOf { error("No TangemShimmer provided") +} + +val LocalMainBottomSheetColor = staticCompositionLocalOf> { + error("No MainBottomSheetColor provided") } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt index d42121d3f0..7c6fa15b74 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt @@ -61,7 +61,14 @@ object DateTimeFormatters { */ val dateMMMMd: DateTimeFormatter by lazy { DateTimeFormatterBuilder() - .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMM d")) + .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "dd MMM")) + .toFormatter() + .withLocale(Locale.getDefault()) + } + + val dateYYYY: DateTimeFormatter by lazy { + DateTimeFormatterBuilder() + .appendPattern(DateFormat.getBestDateTimePattern(Locale.getDefault(), "yyyy")) .toFormatter() .withLocale(Locale.getDefault()) } diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index eda05fed37..ce15e2c6fe 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -91,9 +91,14 @@ internal class DefaultMarketsTokenRepository( ).toBatchFlow() } - override suspend fun getChart(interval: PriceChangeInterval, tokenId: String): TokenChart { + override suspend fun getChart( + fiatCurrencyCode: String, + interval: PriceChangeInterval, + tokenId: String, + ): TokenChart { val response = marketsApi.getCoinChart( - currency = tokenId, + currency = fiatCurrencyCode, + coinId = tokenId, interval = interval.toRequestParam(), ) diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt index f47c0ff170..eee63ef4e9 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenListConfigConverters.kt @@ -20,7 +20,7 @@ fun TokenMarketListConfig.Order.toRequestParam(): String = when (this) { fun PriceChangeInterval.toRequestParam(): String = when (this) { PriceChangeInterval.H24 -> "24h" PriceChangeInterval.WEEK -> "1w" - PriceChangeInterval.MONTH -> "30d" + PriceChangeInterval.MONTH -> "1m" PriceChangeInterval.MONTH3 -> "3m" PriceChangeInterval.MONTH6 -> "6m" PriceChangeInterval.YEAR -> "1y" diff --git a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt index 4eb118ab29..88eccb1163 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/converters/TokenMarketChartsConverter.kt @@ -3,6 +3,7 @@ package com.tangem.data.markets.converters import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarket +import com.tangem.domain.markets.TokenMarketListConfig class TokenMarketChartsConverter( private val tokenChartConverter: TokenChartConverter, @@ -11,23 +12,29 @@ class TokenMarketChartsConverter( fun convert( chartsToCopy: TokenMarket.Charts, tokenId: String, - interval: PriceChangeInterval, + interval: TokenMarketListConfig.Interval, value: TokenMarketChartListResponse, ): TokenMarket.Charts { val prices = requireNotNull(value[tokenId]) { "$tokenId is not found in the response. This shouldn't have happened." } return when (interval) { - PriceChangeInterval.H24 -> chartsToCopy.copy( - h24 = tokenChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.H24 -> chartsToCopy.copy( + h24 = tokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) - PriceChangeInterval.WEEK -> chartsToCopy.copy( - week = tokenChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.WEEK -> chartsToCopy.copy( + week = tokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) - PriceChangeInterval.MONTH -> chartsToCopy.copy( - month = tokenChartConverter.convert(interval, prices), + TokenMarketListConfig.Interval.MONTH -> chartsToCopy.copy( + month = tokenChartConverter.convert(interval.toPriceChangeInterval(), prices), ) else -> error("unsupported interval=$interval. This shouldn't have happened.") } } + + private fun TokenMarketListConfig.Interval.toPriceChangeInterval(): PriceChangeInterval = when (this) { + TokenMarketListConfig.Interval.H24 -> PriceChangeInterval.H24 + TokenMarketListConfig.Interval.WEEK -> PriceChangeInterval.WEEK + TokenMarketListConfig.Interval.MONTH -> PriceChangeInterval.MONTH + } } \ No newline at end of file diff --git a/domain/app-currency/models/build.gradle.kts b/domain/app-currency/models/build.gradle.kts index 7ff7fb7522..6b18f3f83f 100644 --- a/domain/app-currency/models/build.gradle.kts +++ b/domain/app-currency/models/build.gradle.kts @@ -1,4 +1,9 @@ plugins { alias(deps.plugins.kotlin.jvm) + alias(deps.plugins.kotlin.serialization) id("configuration") +} + +dependencies { + implementation(deps.kotlin.serialization) } \ No newline at end of file diff --git a/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt b/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt index 9d5261347a..adadcdcbac 100644 --- a/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt +++ b/domain/app-currency/models/src/main/kotlin/com/tangem/domain/appcurrency/model/AppCurrency.kt @@ -1,5 +1,8 @@ package com.tangem.domain.appcurrency.model +import kotlinx.serialization.Serializable + +@Serializable data class AppCurrency( val code: String, val name: String, diff --git a/domain/markets/build.gradle.kts b/domain/markets/build.gradle.kts index 5529922be2..8545d1c3b3 100644 --- a/domain/markets/build.gradle.kts +++ b/domain/markets/build.gradle.kts @@ -11,9 +11,10 @@ android { dependencies { - api(projects.domain.markets.models) + api(projects.domain.appCurrency.models) api(projects.domain.core) api(projects.core.pagination) + api(projects.domain.markets.models) implementation(deps.kotlin.serialization) implementation(projects.domain.tokens.models) diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt index bcac52fddb..a23a9136ba 100644 --- a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/TokenMarketUpdateRequest.kt @@ -7,7 +7,7 @@ sealed class TokenMarketUpdateRequest { ) : TokenMarketUpdateRequest() data class UpdateChart( - val interval: PriceChangeInterval, + val interval: TokenMarketListConfig.Interval, val currency: String, ) : TokenMarketUpdateRequest() } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt index e2db0be0d3..c686ff3874 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenPriceChartUseCase.kt @@ -1,15 +1,24 @@ package com.tangem.domain.markets import arrow.core.Either +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.repositories.MarketsTokenRepository class GetTokenPriceChartUseCase( private val marketsTokenRepository: MarketsTokenRepository, ) { - suspend operator fun invoke(interval: PriceChangeInterval, tokenId: String): Either { + suspend operator fun invoke( + appCurrency: AppCurrency, + interval: PriceChangeInterval, + tokenId: String, + ): Either { return Either.catch { - marketsTokenRepository.getChart(interval = interval, tokenId = tokenId) + marketsTokenRepository.getChart( + fiatCurrencyCode = appCurrency.code, + interval = interval, + tokenId = tokenId, + ) }.mapLeft {} } } \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index d6cb5a094b..8b3ce43ce5 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -10,5 +10,5 @@ interface MarketsTokenRepository { nextBatchSize: Int, ): TokenListBatchFlow - suspend fun getChart(interval: PriceChangeInterval, tokenId: String): TokenChart + suspend fun getChart(fiatCurrencyCode: String, interval: PriceChangeInterval, tokenId: String): TokenChart } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt index 987d11c0a8..0078651a11 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/DefaultMarketsEntryComponent.kt @@ -1,36 +1,38 @@ package com.tangem.features.markets -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.jetpack.stack.Children -import com.arkivanov.decompose.router.stack.ChildStack -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.childStack -import com.arkivanov.decompose.router.stack.push +import com.arkivanov.decompose.extensions.compose.jetpack.stack.animation.* +import com.arkivanov.decompose.extensions.compose.jetpack.subscribeAsState +import com.arkivanov.decompose.router.stack.* import com.arkivanov.decompose.value.Value import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext -import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.component.BottomSheetState import com.tangem.features.markets.component.MarketsEntryComponent import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent import com.tangem.features.markets.details.api.toSerializable import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent -import com.tangem.features.markets.tokenlist.impl.model.MarketsListModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +@Stable internal class DefaultMarketsEntryComponent @AssistedInject constructor( @Assisted context: AppComponentContext, private val marketsEntryChildFactory: MarketsEntryChildFactory, ) : MarketsEntryComponent, AppComponentContext by context { - private val model: MarketsListModel = getOrCreateModel() - private val stackNavigation = StackNavigation() val stack: Value> = childStack( @@ -44,20 +46,36 @@ internal class DefaultMarketsEntryComponent @AssistedInject constructor( child = configuration, appComponentContext = childByContext(componentContext), onTokenSelected = ::marketsListTokenSelected, + onDetailsBack = ::onDetailsBack, ) }, ) + @Suppress("LongMethod") @Composable override fun BottomSheetContent( bottomSheetState: State, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier, ) { - Children(stack) { + val primary = TangemTheme.colors.background.primary + val secondary = TangemTheme.colors.background.secondary + val backgroundColor = remember { Animatable(primary) } + val stackState = stack.subscribeAsState() + + LocalMainBottomSheetColor.current.value = backgroundColor.value + + Children( + stack = stackState.value, + animation = stackAnimation(slide()), + ) { when (it.configuration) { is MarketsEntryChildFactory.Child.TokenDetails -> { - (it.instance as MarketsTokenDetailsComponent).Content(modifier) + (it.instance as MarketsTokenDetailsComponent).BottomSheetContent( + bottomSheetState = bottomSheetState, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) } MarketsEntryChildFactory.Child.TokenList -> { (it.instance as MarketsTokenListComponent).BottomSheetContent( @@ -68,16 +86,75 @@ internal class DefaultMarketsEntryComponent @AssistedInject constructor( } } } + + val activeChild = stackState.value.active.configuration + + LaunchedEffect(bottomSheetState.value) { + if (activeChild is MarketsEntryChildFactory.Child.TokenDetails) { + when (bottomSheetState.value) { + BottomSheetState.EXPANDED -> { + backgroundColor.animateTo( + secondary, + animationSpec = tween(durationMillis = 100), + ) + } + BottomSheetState.COLLAPSED -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 100), + ) + } + } + } + } + + LaunchedEffect(activeChild) { + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.animateTo( + secondary, + animationSpec = tween(durationMillis = 500), + ) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.animateTo( + primary, + animationSpec = tween(durationMillis = 500), + ) + } + } + } + + LaunchedEffect(primary, secondary) { + if (backgroundColor.isRunning) return@LaunchedEffect + + when (activeChild) { + is MarketsEntryChildFactory.Child.TokenDetails -> { + backgroundColor.snapTo(secondary) + } + MarketsEntryChildFactory.Child.TokenList -> { + backgroundColor.snapTo(primary) + } + } + } } - private fun marketsListTokenSelected(token: TokenMarket) { - stackNavigation.push( + @OptIn(ExperimentalDecomposeApi::class) + private fun marketsListTokenSelected(token: TokenMarket, appCurrency: AppCurrency) { + stackNavigation.pushNew( configuration = MarketsEntryChildFactory.Child.TokenDetails( - params = MarketsTokenDetailsComponent.Params(token.toSerializable()), + params = MarketsTokenDetailsComponent.Params( + token = token.toSerializable(), + appCurrency = appCurrency, + ), ), ) } + private fun onDetailsBack() { + stackNavigation.popWhile { it != MarketsEntryChildFactory.Child.TokenList } + } + @AssistedFactory interface Factory : MarketsEntryComponent.Factory { override fun create(context: AppComponentContext): DefaultMarketsEntryComponent diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt index 9ad07ec122..0db378d0f5 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/MarketsEntryChildFactory.kt @@ -1,6 +1,8 @@ package com.tangem.features.markets +import androidx.compose.runtime.Immutable import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent @@ -13,25 +15,30 @@ internal class MarketsEntryChildFactory @Inject constructor( ) { @Serializable + @Immutable sealed interface Child { @Serializable + @Immutable data object TokenList : Child @Serializable + @Immutable data class TokenDetails(val params: MarketsTokenDetailsComponent.Params) : Child } fun createChild( child: Child, appComponentContext: AppComponentContext, - onTokenSelected: (TokenMarket) -> Unit, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, + onDetailsBack: () -> Unit, ): Any { return when (child) { is Child.TokenDetails -> { tokenDetailsComponentFactory.create( context = appComponentContext, params = child.params, + onBack = onDetailsBack, ) } is Child.TokenList -> { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt index 74407f6910..a706e5850e 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/api/MarketsTokenDetailsComponent.kt @@ -1,15 +1,32 @@ package com.tangem.features.markets.details.api +import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.markets.component.BottomSheetState import kotlinx.serialization.Serializable @Stable -interface MarketsTokenDetailsComponent : ComposableContentComponent { +interface MarketsTokenDetailsComponent { @Serializable - data class Params(val token: TokenMarketSerializable) + data class Params( + val token: TokenMarketSerializable, + val appCurrency: AppCurrency, + ) - interface Factory : ComponentFactory + @Composable + fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) + + interface Factory { + fun create(context: AppComponentContext, params: Params, onBack: () -> Unit): MarketsTokenDetailsComponent + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt index ad994d8f70..5bc189be06 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/DefaultMarketsTokenDetailsComponent.kt @@ -2,11 +2,14 @@ package com.tangem.features.markets.details.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.markets.component.BottomSheetState import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent import com.tangem.features.markets.details.impl.model.MarketsTokenDetailsModel import com.tangem.features.markets.details.impl.ui.MarketsTokenDetailsContent @@ -18,17 +21,23 @@ import dagger.assisted.AssistedInject internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: MarketsTokenDetailsComponent.Params, + @Assisted private val onBack: () -> Unit, ) : AppComponentContext by appComponentContext, MarketsTokenDetailsComponent { private val model: MarketsTokenDetailsModel = getOrCreateModel(params) @Composable - override fun Content(modifier: Modifier) { + override fun BottomSheetContent( + bottomSheetState: State, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier, + ) { val state by model.state.collectAsStateWithLifecycle() MarketsTokenDetailsContent( state = state, - onBackClick = {}, + onBackClick = { onBack() }, + onHeaderSizeChange = onHeaderSizeChange, modifier = modifier, ) } @@ -38,6 +47,7 @@ internal class DefaultMarketsTokenDetailsComponent @AssistedInject constructor( override fun create( context: AppComponentContext, params: MarketsTokenDetailsComponent.Params, + onBack: () -> Unit, ): DefaultMarketsTokenDetailsComponent } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt index e2bece6eca..f1814f03df 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/MarketsTokenDetailsModel.kt @@ -1,60 +1,254 @@ package com.tangem.features.markets.details.impl.model -import com.tangem.common.ui.charts.state.MarketChartData -import com.tangem.common.ui.charts.state.MarketChartDataProducer -import com.tangem.common.ui.charts.state.MarketChartLook +import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.ui.charts.state.* import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.GetTokenPriceChartUseCase import com.tangem.domain.markets.PriceChangeInterval import com.tangem.features.markets.details.api.MarketsTokenDetailsComponent import com.tangem.features.markets.details.impl.ui.entity.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.math.BigDecimal +import java.math.RoundingMode import javax.inject.Inject +@Stable internal class MarketsTokenDetailsModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, ) : Model() { val params = paramsContainer.require() + private val currentAppCurrency = getSelectedAppCurrencyUseCase() + .map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = params.appCurrency, + ) + private val chartDataProducer = MarketChartDataProducer.build(dispatcher = dispatchers.default) { chartData = MarketChartData.NoData.Loading + + updateLook { + it.copy( + type = getChartTypeByPercent(params.token.tokenQuotes.h24Percent), + xAxisFormatter = { value -> + value.toLong().toTimeFormat(DateTimeFormatters.timeFormatter) + }, + yAxisFormatter = { value -> + BigDecimalFormatter.formatFiatAmountUncapped( + fiatAmount = value, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = "", + ) + }, + ) + } } val state = MutableStateFlow( MarketsTokenDetailsUM( tokenName = params.token.name, - dateTimeText = "datetime", - priceChangePercentText = params.token.tokenQuotes.h24Percent.toString(), + priceText = BigDecimalFormatter.formatFiatAmountUncapped( + fiatAmount = params.token.tokenQuotes.currentPrice, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ), + dateTimeText = resourceReference(R.string.common_today), + priceChangePercentText = BigDecimalFormatter.formatPercent( + percent = params.token.tokenQuotes.h24Percent, + useAbsoluteValue = true, + ), priceChangeType = if (params.token.tokenQuotes.h24Percent < BigDecimal.ZERO) { PriceChangeType.DOWN } else { PriceChangeType.UP }, + iconUrl = params.token.imageUrl, chartState = MarketsTokenDetailsUM.ChartState( dataProducer = chartDataProducer, chartLook = MarketChartLook(), onLoadRetryClick = {}, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = ::onMarkerPointSelected, ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = ::onSelectedIntervalChange, ), ) + private val loadChartJobHolder = JobHolder() + init { + loadChart(PriceChangeInterval.H24) + } + + private fun onSelectedIntervalChange(interval: PriceChangeInterval) { + if (state.value.selectedInterval == interval) return + + state.update { + it.copy( + selectedInterval = interval, + priceChangeType = PriceChangeType.UP, + ) + } + + loadChart(interval) + } + + private fun loadChart(interval: PriceChangeInterval) { modelScope.launch { - val chart = getTokenPriceChartUseCase.invoke(PriceChangeInterval.H24, params.token.id) + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + ), + ) + } + + chartDataProducer.runTransactionSuspend { + chartData = MarketChartData.NoData.Loading + } + + val chart = getTokenPriceChartUseCase.invoke( + appCurrency = currentAppCurrency.value, + interval = interval, + tokenId = params.token.id, + ) + + state.update { + it.copy( + selectedInterval = interval, + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + ), + ) + } + + val xAxisFormatter = getFormatterByInterval(state.value.selectedInterval) chart.onRight { chartDataProducer.runTransactionSuspend { - chartData = MarketChartData.Data() + chartData = MarketChartData.Data( + x = it.timeStamps.map { it.toBigDecimal() }.toImmutableList(), + y = it.priceY.toImmutableList(), + ) + + updateLook { + it.copy( + xAxisFormatter = xAxisFormatter, + ) + } } + + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.DATA, + ), + ) + } + }.onLeft { + state.update { + it.copy( + chartState = it.chartState.copy( + status = MarketsTokenDetailsUM.ChartState.Status.ERROR, + ), + ) + } + } + }.saveIn(loadChartJobHolder) + } + + private fun getFormatterByInterval(interval: PriceChangeInterval): (BigDecimal) -> String { + return when (interval) { + PriceChangeInterval.H24 -> { value: BigDecimal -> + value.toLong().toTimeFormat(DateTimeFormatters.timeFormatter) + } + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + -> { value -> + value.toLong().toTimeFormat(DateTimeFormatters.dateMMMMd) + } + PriceChangeInterval.YEAR -> { value -> + value.toLong().toTimeFormat(DateTimeFormatters.dateMMMMd) + } + PriceChangeInterval.ALL_TIME -> { value -> + value.toLong().toTimeFormat(DateTimeFormatters.dateYYYY) } } } + + @Suppress("MagicNumber") + private fun onMarkerPointSelected(time: BigDecimal?, price: BigDecimal?) { + val timeText = time?.toLong()?.toTimeFormat(DateTimeFormatters.dateTimeFormatter)?.let { + resourceReference(R.string.common_range, wrappedList(it, resourceReference(R.string.common_now))) + } ?: resourceReference(R.string.common_today) + + val percent = price?.subtract(params.token.tokenQuotes.currentPrice) + ?.divide(params.token.tokenQuotes.currentPrice, 4, RoundingMode.HALF_UP) + ?.multiply(BigDecimal(-100)) + ?: params.token.tokenQuotes.h24Percent + + val percentText = BigDecimalFormatter.formatPercent( + percent = percent, + useAbsoluteValue = true, + ) + + state.update { + it.copy( + dateTimeText = timeText, + priceText = BigDecimalFormatter.formatFiatAmountUncapped( + fiatAmount = price ?: params.token.tokenQuotes.currentPrice, + fiatCurrencyCode = currentAppCurrency.value.code, + fiatCurrencySymbol = currentAppCurrency.value.symbol, + ), + priceChangePercentText = percentText, + priceChangeType = when { + percent < BigDecimal.ZERO -> PriceChangeType.DOWN + percent > BigDecimal.ZERO -> PriceChangeType.UP + else -> PriceChangeType.NEUTRAL + }, + ) + } + + chartDataProducer.runTransaction { + updateLook { + it.copy( + type = getChartTypeByPercent(percent), + ) + } + } + } + + private fun getChartTypeByPercent(percent: BigDecimal): MarketChartLook.Type { + return if (percent >= BigDecimal.ZERO) { + MarketChartLook.Type.Growing + } else { + MarketChartLook.Type.Falling + } + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt index 9f091059d5..ead8e056f4 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/MarketsTokenDetailsContent.kt @@ -1,31 +1,223 @@ package com.tangem.features.markets.details.impl.ui -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.tangem.common.ui.charts.state.MarketChartDataProducer +import com.tangem.common.ui.charts.state.MarketChartLook +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.components.SpacerH4 +import com.tangem.core.ui.components.SpacerW4 import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons +import com.tangem.core.ui.components.currency.icon.CoinIcon +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.markets.PriceChangeInterval +import com.tangem.features.markets.details.impl.ui.components.MarketTokenDetailsChart import com.tangem.features.markets.details.impl.ui.entity.MarketsTokenDetailsUM +import com.tangem.features.markets.impl.R +import kotlinx.collections.immutable.persistentListOf @Suppress("UnusedPrivateMember") @Composable internal fun MarketsTokenDetailsContent( state: MarketsTokenDetailsUM, onBackClick: () -> Unit, + onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier, ) { - // TODO + Content( + state = state, + onBackClick = onBackClick, + onHeaderSizeChange = onHeaderSizeChange, + modifier = modifier, + ) } @Suppress("UnusedPrivateMember") @Composable -private fun Content(state: MarketsTokenDetailsUM, onBackClick: () -> Unit, modifier: Modifier = Modifier) { - Column { +private fun Content( + state: MarketsTokenDetailsUM, + onBackClick: () -> Unit, + onHeaderSizeChange: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + val backgroundColor = LocalMainBottomSheetColor.current.value + val density = LocalDensity.current + + Column( + modifier = modifier + .drawBehind { drawRect(backgroundColor) } + .fillMaxSize(), + ) { TangemTopAppBar( + modifier = Modifier.onGloballyPositioned { + if (it.size.height > 0) { + with(density) { + onHeaderSizeChange(it.size.height.toDp()) + } + } + }, title = state.tokenName, startButton = TopAppBarButtonUM.Back(onBackClick), ) - } + SpacerH4() - // TODO + Header( + state = state, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + + SpacerH16() + + IntervalSelector( + trendInterval = state.selectedInterval, + onIntervalClick = state.onSelectedIntervalChange, + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + ) + + SpacerH32() + + MarketTokenDetailsChart( + modifier = Modifier.fillMaxWidth(), + state = state.chartState, + ) + } +} + +@Composable +private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = state.priceText, + style = TangemTheme.typography.head, + color = TangemTheme.colors.text.primary1, + ) + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) { + Text( + text = state.dateTimeText.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + PriceChangeInPercent( + valueInPercent = state.priceChangePercentText, + type = state.priceChangeType, + textStyle = TangemTheme.typography.caption2, + ) + } + } + SpacerW4() + CoinIcon( + modifier = Modifier.size(TangemTheme.dimens.size48), + url = state.iconUrl, + alpha = 1f, + colorFilter = null, + fallbackResId = R.drawable.ic_custom_token_44, + ) + } +} + +@Composable +private fun IntervalSelector( + trendInterval: PriceChangeInterval, + onIntervalClick: (PriceChangeInterval) -> Unit, + modifier: Modifier = Modifier, +) { + SegmentedButtons( + config = persistentListOf( + PriceChangeInterval.H24, + PriceChangeInterval.WEEK, + PriceChangeInterval.MONTH, + PriceChangeInterval.MONTH3, + PriceChangeInterval.MONTH6, + PriceChangeInterval.YEAR, + PriceChangeInterval.ALL_TIME, + ), + color = TangemTheme.colors.button.secondary, + initialSelectedItem = trendInterval, + onClick = onIntervalClick, + modifier = modifier, + ) { + Box( + Modifier + .fillMaxSize() + .align(Alignment.Center) + .padding( + vertical = TangemTheme.dimens.spacing4, + ), + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = it.getText().resolveReference(), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + ) + } + } +} + +@Composable +fun PriceChangeInterval.getText(): TextReference { + return when (this) { + PriceChangeInterval.H24 -> resourceReference(R.string.markets_selector_interval_24h_title) + PriceChangeInterval.WEEK -> resourceReference(R.string.markets_selector_interval_7d_title) + PriceChangeInterval.MONTH -> resourceReference(R.string.markets_selector_interval_1m_title) + PriceChangeInterval.MONTH3 -> resourceReference(R.string.markets_selector_interval_3m_title) + PriceChangeInterval.MONTH6 -> resourceReference(R.string.markets_selector_interval_6m_title) + PriceChangeInterval.YEAR -> resourceReference(R.string.markets_selector_interval_1y_title) + PriceChangeInterval.ALL_TIME -> resourceReference(R.string.markets_selector_interval_all_title) + } +} + +@Preview +@Composable +private fun Preview() { + TangemThemePreview { + Content( + state = MarketsTokenDetailsUM( + tokenName = "Token Name", + priceText = "Price", + dateTimeText = stringReference("Date Time"), + priceChangePercentText = "Price Change", + iconUrl = "", + priceChangeType = PriceChangeType.UP, + chartState = MarketsTokenDetailsUM.ChartState( + dataProducer = MarketChartDataProducer.build { }, + chartLook = MarketChartLook(), + onLoadRetryClick = {}, + status = MarketsTokenDetailsUM.ChartState.Status.LOADING, + onMarkerPointSelected = { _, _ -> }, + ), + selectedInterval = PriceChangeInterval.H24, + onSelectedIntervalChange = { }, + ), + onHeaderSizeChange = {}, + onBackClick = {}, + ) + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt index 8a92bdd1f3..d138a1c23d 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/components/MarketTokenDetailsChart.kt @@ -1,29 +1,76 @@ package com.tangem.features.markets.details.impl.ui.components +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import com.tangem.common.ui.charts.MarketChart +import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.common.ui.charts.state.rememberMarketChartState +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.features.markets.details.impl.ui.entity.MarketsTokenDetailsUM import com.tangem.features.markets.tokenlist.impl.ui.components.UnableToLoadData @Composable fun MarketTokenDetailsChart(state: MarketsTokenDetailsUM.ChartState, modifier: Modifier = Modifier) { - val chartState = rememberMarketChartState(state.dataProducer) + val growingColor = TangemTheme.colors.icon.accent + val fallingColor = TangemTheme.colors.icon.warning - MarketChart( - modifier = modifier, - state = chartState, - noChartContent = { - UnableToLoadData( - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12) - .align(Alignment.Center), - onRetryClick = state.onLoadRetryClick, - ) + val chartState = rememberMarketChartState( + dataProducer = state.dataProducer, + colorMapper = { + when (it) { + MarketChartLook.Type.Growing -> growingColor + MarketChartLook.Type.Falling -> fallingColor + } }, + onMarkerShown = state.onMarkerPointSelected, ) + + val backgroundColor = LocalMainBottomSheetColor.current.value + + Box(modifier) { + MarketChart( + modifier = Modifier.fillMaxWidth(), + state = chartState, + ) + + if (state.status != MarketsTokenDetailsUM.ChartState.Status.DATA) { + Box( + Modifier + .drawBehind { drawRect(backgroundColor) } + .matchParentSize(), + ) { + when (state.status) { + MarketsTokenDetailsUM.ChartState.Status.LOADING -> { + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size16) + .align(Alignment.Center), + color = TangemTheme.colors.text.accent, + strokeWidth = TangemTheme.dimens.size2, + ) + } + MarketsTokenDetailsUM.ChartState.Status.ERROR -> { + UnableToLoadData( + modifier = Modifier + .padding( + horizontal = TangemTheme.dimens.spacing16, + vertical = TangemTheme.dimens.spacing12, + ) + .align(Alignment.Center), + onRetryClick = state.onLoadRetryClick, + ) + } + else -> {} + } + } + } + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/entity/MarketsTokenDetailsUM.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/entity/MarketsTokenDetailsUM.kt index 2599cd5f21..48941c6461 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/entity/MarketsTokenDetailsUM.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/ui/entity/MarketsTokenDetailsUM.kt @@ -3,18 +3,31 @@ package com.tangem.features.markets.details.impl.ui.entity import com.tangem.common.ui.charts.state.MarketChartDataProducer import com.tangem.common.ui.charts.state.MarketChartLook import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.markets.PriceChangeInterval +import java.math.BigDecimal data class MarketsTokenDetailsUM( val tokenName: String, - val dateTimeText: String, + val priceText: String, + val iconUrl: String, + val dateTimeText: TextReference, val priceChangePercentText: String, val priceChangeType: PriceChangeType, + val selectedInterval: PriceChangeInterval, val chartState: ChartState, + val onSelectedIntervalChange: (PriceChangeInterval) -> Unit, ) { data class ChartState( + val status: Status, val dataProducer: MarketChartDataProducer, val chartLook: MarketChartLook, val onLoadRetryClick: () -> Unit, - ) + val onMarkerPointSelected: (time: BigDecimal?, price: BigDecimal?) -> Unit, + ) { + enum class Status { + LOADING, ERROR, DATA + } + } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt index 849eaf46fd..6843a4d610 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/api/MarketsTokenListComponent.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.component.BottomSheetState @@ -20,6 +21,9 @@ interface MarketsTokenListComponent { ) interface Factory { - fun create(context: AppComponentContext, onTokenSelected: (TokenMarket) -> Unit): MarketsTokenListComponent + fun create( + context: AppComponentContext, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, + ): MarketsTokenListComponent } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt index 783559bd3c..5a73f13956 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/DefaultMarketsTokenListComponent.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.unit.Dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.component.BottomSheetState import com.tangem.features.markets.tokenlist.api.MarketsTokenListComponent @@ -22,14 +23,14 @@ import kotlinx.coroutines.flow.onEach class DefaultMarketsTokenListComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted private val onTokenSelected: (TokenMarket) -> Unit, + @Assisted private val onTokenSelected: (TokenMarket, AppCurrency) -> Unit, ) : AppComponentContext by appComponentContext, MarketsTokenListComponent { private val model: MarketsListModel = getOrCreateModel() init { model.tokenSelected - .onEach { onTokenSelected(it) } + .onEach { onTokenSelected(it.first, it.second) } .launchIn(componentScope) } @@ -58,7 +59,7 @@ class DefaultMarketsTokenListComponent @AssistedInject constructor( interface Factory : MarketsTokenListComponent.Factory { override fun create( context: AppComponentContext, - onTokenSelected: (TokenMarket) -> Unit, + onTokenSelected: (TokenMarket, AppCurrency) -> Unit, ): DefaultMarketsTokenListComponent } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt index 06fe406bca..b2aca92901 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/MarketsListModel.kt @@ -77,7 +77,7 @@ internal class MarketsListModel @Inject constructor( private var activeListManager: MarketsListBatchFlowManager = mainMarketsListManager - private val _tokenSelected = MutableSharedFlow() + private val _tokenSelected = MutableSharedFlow>() val tokenSelected = _tokenSelected.asSharedFlow() @@ -223,7 +223,7 @@ internal class MarketsListModel @Inject constructor( private fun onTokenUIClicked(token: MarketsListItemUM) { modelScope.launch { activeListManager.getTokenById(token.id)?.let { found -> - _tokenSelected.emit(found) + _tokenSelected.emit(found to currentAppCurrency.value) } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt index 17050e129d..f27ba0a768 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.markets.tokenlist.impl.model.converters -import com.tangem.common.ui.charts.state.DefaultPointValuesConverter +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.marketprice.PriceChangeType @@ -10,6 +10,7 @@ import com.tangem.domain.markets.TokenMarket import com.tangem.features.markets.tokenlist.impl.ui.entity.MarketsListItemUM import com.tangem.features.markets.tokenlist.impl.ui.entity.MarketsListUM.TrendInterval import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal import java.math.RoundingMode @@ -18,6 +19,8 @@ internal class MarketsTokenItemConverter( private val appCurrency: AppCurrency, ) : Converter { + private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(needToFormatAxis = false) + override fun convert(value: TokenMarket): MarketsListItemUM { return MarketsListItemUM( id = value.id, @@ -107,10 +110,10 @@ internal class MarketsTokenItemConverter( } return chart?.let { ct -> - DefaultPointValuesConverter.convert( + priceAndTimePointValuesConverter.convert( MarketChartData.Data( - y = ct.priceY, - x = ct.timeStamps.map { it.toBigDecimal() }, + y = ct.priceY.toImmutableList(), + x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), ), ) } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt index 42a790727c..ba4b9aba33 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/statemanager/MarketsListBatchFlowManager.kt @@ -233,7 +233,7 @@ internal class MarketsListBatchFlowManager( BatchAction.UpdateBatches( keys = batchesKeysToLoad, updateRequest = TokenMarketUpdateRequest.UpdateChart( - interval = interval.toRequestInterval(), + interval = interval.toBatchRequestInterval(), currency = currentAppCurrency().code, ), async = true, @@ -303,14 +303,6 @@ internal class MarketsListBatchFlowManager( } } - private fun TrendInterval.toRequestInterval(): PriceChangeInterval { - return when (this) { - TrendInterval.H24 -> PriceChangeInterval.H24 - TrendInterval.D7 -> PriceChangeInterval.WEEK - TrendInterval.M1 -> PriceChangeInterval.MONTH - } - } - private fun Flow.onEachWithPrevious(operation: suspend (prev: T?, value: T) -> Unit): Flow = flow { var prev: T? = null collect { value -> diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt index 72f70d8971..56f0361cff 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/MarketsList.kt @@ -1,16 +1,15 @@ package com.tangem.features.markets.tokenlist.impl.ui +import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -32,6 +31,7 @@ import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.markets.component.BottomSheetState @@ -68,23 +68,28 @@ internal fun MarketsList( @Composable private fun Content(state: MarketsListUM, onHeaderSizeChange: (Dp) -> Unit, modifier: Modifier = Modifier) { val density = LocalDensity.current + val background = LocalMainBottomSheetColor.current.value Column( modifier = modifier .fillMaxSize() .imePadding() - .background(color = TangemTheme.colors.background.primary), + .drawBehind { drawRect(background) }, ) { SearchBar( modifier = Modifier - .background(color = TangemTheme.colors.background.primary) + .drawBehind { drawRect(background) } .padding( start = TangemTheme.dimens.spacing16, end = TangemTheme.dimens.spacing16, bottom = TangemTheme.dimens.spacing4, ) .onGloballyPositioned { - with(density) { onHeaderSizeChange(it.size.height.toDp()) } + if (it.size.height > 0) { + with(density) { + onHeaderSizeChange(it.size.height.toDp()) + } + } }, state = state.searchBar, ) @@ -225,45 +230,52 @@ private fun KeyboardEvents(isSortByBottomSheetShown: Boolean, bottomSheetState: //region: Preview @Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun Preview() { - TangemThemePreview { - MarketsList( - state = MarketsListUM( - list = ListUM.Content( - items = MarketChartListItemPreviewDataProvider().values - .flatMap { item -> List(size = 10) { item } } - .mapIndexed { index, item -> - item.copy(id = index.toString()) - } - .toImmutableList(), - showUnder100kTokens = false, - loadMore = {}, - visibleIdsChanged = {}, - onShowTokensUnder100kClicked = {}, - triggerScrollReset = consumedEvent(), - onItemClick = {}, + TangemThemePreview(alwaysShowBottomSheets = false) { + val primaryBackground = TangemTheme.colors.background.primary + + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(primaryBackground) }, + ) { + MarketsList( + state = MarketsListUM( + list = ListUM.Content( + items = MarketChartListItemPreviewDataProvider().values + .flatMap { item -> List(size = 10) { item } } + .mapIndexed { index, item -> + item.copy(id = index.toString()) + } + .toImmutableList(), + showUnder100kTokens = false, + loadMore = {}, + visibleIdsChanged = {}, + onShowTokensUnder100kClicked = {}, + triggerScrollReset = consumedEvent(), + onItemClick = {}, + ), + searchBar = SearchBarUM( + placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = { }, + ), + selectedSortBy = SortByTypeUM.Rating, + selectedInterval = MarketsListUM.TrendInterval.H24, + onIntervalClick = {}, + onSortByButtonClick = {}, + sortByBottomSheet = TangemBottomSheetConfig( + isShow = false, + onDismissRequest = {}, + content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, + ), ), - searchBar = SearchBarUM( - placeholderText = resourceReference(R.string.manage_tokens_search_placeholder), - query = "", - onQueryChange = {}, - isActive = false, - onActiveChange = { }, - ), - selectedSortBy = SortByTypeUM.Rating, - selectedInterval = MarketsListUM.TrendInterval.H24, - onIntervalClick = {}, - onSortByButtonClick = {}, - sortByBottomSheet = TangemBottomSheetConfig( - isShow = false, - onDismissRequest = {}, - content = SortByBottomSheetContentUM(selectedOption = SortByTypeUM.Rating) {}, - ), - ), - onHeaderSizeChange = {}, - bottomSheetState = BottomSheetState.EXPANDED, - ) + onHeaderSizeChange = {}, + bottomSheetState = BottomSheetState.EXPANDED, + ) + } } } diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt index 56c53b9a72..8745966639 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/components/MarketsListLazyColumn.kt @@ -89,7 +89,7 @@ internal fun MarketsListLazyColumn( is ListUM.Content -> { items( items = state.items, - key = { it.id }, + key = { it.id + it.marketCap.toString() }, ) { item -> MarketsListItem( model = item, diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt index fd5efe0400..5cc524cd87 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/ui/preview/MarketChartListItemPreviewDataProvider.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.features.markets.tokenlist.impl.ui.entity.MarketsListItemUM +import kotlinx.collections.immutable.persistentListOf internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParameterProvider( collection = listOf( @@ -19,7 +20,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -45,7 +46,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.DOWN, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -59,7 +60,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -73,7 +74,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), MarketsListItemUM( @@ -87,7 +88,7 @@ internal class MarketChartListItemPreviewDataProvider : CollectionPreviewParamet trendPercentText = "12.43%", trendType = PriceChangeType.UP, chardData = MarketChartRawData( - y = listOf(0.4f, 0.2f, 0.4f, 0.1f, 0.4f, 2f, 5f, 0.1f, 2f, 2f, 3f), + y = persistentListOf(0.4, 0.2, 0.4, 0.1, 0.4, 2.0, 5.0, 0.1, 2.0, 2.0, 3.0), ), ), ), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt index d671a0c9c5..868de285aa 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/WalletFragment.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation import android.os.Bundle import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.arkivanov.decompose.defaultComponentContext import com.tangem.core.decompose.context.DefaultAppComponentContext @@ -68,7 +69,11 @@ internal class WalletFragment : ComposeFragment() { @Composable override fun ScreenContent(modifier: Modifier) { _walletRouter.Initialize( - onFinish = requireActivity()::finish, + onFinish = remember(requireActivity()) { + { + requireActivity().finish() + } + }, marketsEntryComponent = marketsEntryComponent, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt index 9285575d9e..160e0d11da 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletScreenState.kt @@ -1,8 +1,10 @@ package com.tangem.feature.wallet.presentation.wallet.state.model +import androidx.compose.runtime.Immutable import com.tangem.core.ui.event.StateEvent import kotlinx.collections.immutable.ImmutableList +@Immutable internal data class WalletScreenState( val onBackClick: () -> Unit, val topBarConfig: WalletTopBarConfig, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index a6bb2a02db..b8dd2da1b7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -49,6 +49,8 @@ import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar import com.tangem.core.ui.components.snackbar.TangemSnackbar import com.tangem.core.ui.components.transactions.state.TxHistoryState import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.res.LocalMainBottomSheetColor +import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TestTags @@ -70,6 +72,7 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.balances import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.depositButton import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator import com.tangem.features.markets.component.BottomSheetState +import com.tangem.features.markets.component.BottomSheetState.* import com.tangem.features.markets.component.MarketsEntryComponent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @@ -316,15 +319,15 @@ private fun BaseScaffold( @Suppress("LongParameterList", "LongMethod") @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable -private fun BaseScaffoldWithMarkets( +private inline fun BaseScaffoldWithMarkets( state: WalletScreenState, selectedWallet: WalletState, snackbarHostState: SnackbarHostState, bottomSheetHeaderHeightProvider: () -> Dp, - bottomSheetContent: @Composable () -> Unit, + crossinline bottomSheetContent: @Composable () -> Unit, alertConfig: WalletAlertState?, - onBottomSheetStateChange: (BottomSheetState) -> Unit, - content: @Composable () -> Unit, + noinline onBottomSheetStateChange: (BottomSheetState) -> Unit, + crossinline content: @Composable () -> Unit, ) { // show the bottom sheet if there is at least one multicurrency wallet val showManageTokensBottomSheet = remember(state.wallets) { @@ -332,11 +335,13 @@ private fun BaseScaffoldWithMarkets( } val bottomSheetState = rememberSheetStateEnhanced( initialValue = if (showManageTokensBottomSheet) SheetValue.PartiallyExpanded else SheetValue.Hidden, - confirmValueChange = { sheetValue -> - when { - sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false - sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false - else -> true + confirmValueChange = remember(showManageTokensBottomSheet) { + { sheetValue -> + when { + sheetValue == SheetValue.Hidden && showManageTokensBottomSheet -> false + sheetValue != SheetValue.Hidden && !showManageTokensBottomSheet -> false + else -> true + } } }, skipHiddenState = showManageTokensBottomSheet, @@ -344,14 +349,6 @@ private fun BaseScaffoldWithMarkets( val keyboardShown = keyboardAsState() - BottomSheetStateEffects( - bottomSheetState = bottomSheetState, - showManageTokensBottomSheet = showManageTokensBottomSheet, - alertConfig = alertConfig, - keyboardShown = keyboardShown, - onBottomSheetStateChange = onBottomSheetStateChange, - ) - val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = bottomSheetState, snackbarHostState = snackbarHostState, @@ -360,77 +357,89 @@ private fun BaseScaffoldWithMarkets( val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val statusBarHeight = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() } val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight + val maxHeight = LocalWindowSize.current.height val coroutineScope = rememberCoroutineScope() + val backgroundPrimary = TangemTheme.colors.background.primary - BottomSheetScaffold( - snackbarHost = { - WalletSnackbarHost( - snackbarHostState = it, - event = state.event, - modifier = Modifier - .padding(bottom = TangemTheme.dimens.spacing4) - .navigationBarsPadding(), - ) - }, - containerColor = TangemTheme.colors.background.secondary, - sheetContainerColor = TangemTheme.colors.background.primary, - scaffoldState = scaffoldState, - sheetPeekHeight = peekHeight, - sheetDragHandle = { - Hand(modifier = Modifier.background(color = TangemTheme.colors.background.primary)) - }, - sheetTonalElevation = 8.dp, - sheetShadowElevation = 8.dp, - sheetContent = { - BoxWithConstraints { - Box( + CompositionLocalProvider( + LocalMainBottomSheetColor provides remember { mutableStateOf(backgroundPrimary) }, + ) { + val backgroundColor = LocalMainBottomSheetColor.current + + BottomSheetStateEffects( + bottomSheetState = bottomSheetState, + showManageTokensBottomSheet = showManageTokensBottomSheet, + alertConfig = alertConfig, + keyboardShown = keyboardShown, + onBottomSheetStateChange = onBottomSheetStateChange, + ) + + BottomSheetScaffold( + snackbarHost = { + WalletSnackbarHost( + snackbarHostState = it, + event = state.event, modifier = Modifier - .sizeIn(maxHeight = maxHeight - statusBarHeight) - .align(Alignment.BottomCenter), + .padding(bottom = TangemTheme.dimens.spacing4) + .navigationBarsPadding(), + ) + }, + containerColor = TangemTheme.colors.background.secondary, + sheetContainerColor = backgroundColor.value, + scaffoldState = scaffoldState, + sheetPeekHeight = peekHeight, + sheetDragHandle = { + Hand(modifier = Modifier.background(color = backgroundColor.value)) + }, + sheetTonalElevation = 8.dp, + sheetShadowElevation = 8.dp, + sheetContent = { + Box( + modifier = Modifier.sizeIn(maxHeight = maxHeight - statusBarHeight - handComposableComponentHeight), ) { bottomSheetContent() } - } - // hide bottom sheet when back pressed - BackHandler( - keyboardShown.value is Keyboard.Closed && - bottomSheetState.currentValue == SheetValue.Expanded, - ) { - coroutineScope.launch { bottomSheetState.partialExpand() } - } - }, - content = { _ -> - val pullRefreshState = rememberPullRefreshState( - refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, - onRefresh = { - selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true)) - }, - ) - - Column { - WalletTopBar(config = state.topBarConfig) - Box( - modifier = Modifier.pullRefresh(pullRefreshState), + // hide bottom sheet when back pressed + BackHandler( + keyboardShown.value is Keyboard.Closed && + bottomSheetState.currentValue == SheetValue.Expanded, ) { - content() - - WalletPullToRefreshIndicator( - isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter), - ) + coroutineScope.launch { bottomSheetState.partialExpand() } } - } + }, + content = { _ -> + val pullRefreshState = rememberPullRefreshState( + refreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + onRefresh = { + selectedWallet.pullToRefreshConfig.onRefresh(WalletPullToRefreshConfig.ShowRefreshState(true)) + }, + ) - BottomSheetScrim( - color = BottomSheetDefaults.ScrimColor, - visible = bottomSheetState.targetValue == SheetValue.Expanded, - onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, - ) - }, - ) + Column { + WalletTopBar(config = state.topBarConfig) + Box( + modifier = Modifier.pullRefresh(pullRefreshState), + ) { + content() + + WalletPullToRefreshIndicator( + isRefreshing = selectedWallet.pullToRefreshConfig.isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + + BottomSheetScrim( + color = BottomSheetDefaults.ScrimColor, + visible = bottomSheetState.targetValue == SheetValue.Expanded, + onDismissRequest = { coroutineScope.launch { bottomSheetState.partialExpand() } }, + ) + }, + ) + } } @Composable @@ -545,9 +554,9 @@ private fun BottomSheetStateEffects( LaunchedEffect(isSheetHidden) { onBottomSheetStateChange( if (isSheetHidden) { - BottomSheetState.COLLAPSED + COLLAPSED } else { - BottomSheetState.EXPANDED + EXPANDED }, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt index 3a9681b918..f4ac9a87e3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/viewmodels/WalletViewModel.kt @@ -1,5 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.viewmodels +import androidx.compose.runtime.Stable import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -44,6 +45,7 @@ import timber.log.Timber import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") +@Stable @HiltViewModel internal class WalletViewModel @Inject constructor( private val stateHolder: WalletStateController, diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 1c1542e3b1..5dd3fc5ba9 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -92,7 +92,7 @@ tangemBlockchainSdk = "develop-713" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-375" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ -tangemVico = "2.0.0-alpha.21-tangem14" +tangemVico = "2.0.0-alpha.25-tangem16" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ # endregion Tangem