Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-01 14:48:07 +02:00
parent 88783a797e
commit 54d1ea4973
6 changed files with 96 additions and 93 deletions

View file

@ -42,7 +42,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.state.DonutSegment
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import kotlin.math.atan2
import kotlin.math.hypot
import kotlin.math.min
@ -71,7 +71,7 @@ import kotlin.math.min
* yet). Taps are hit-tested against the ring band only and reported via [onSegmentClick]; the chart is
* interactive only when [onSegmentClick] is set **and** [segments] is non-empty.
*
* @param segments Slices, in priority order (index 0 is painted on top). See [DonutSegment.weight].
* @param segments Slices, in priority order (index 0 is painted on top). See [DonutSegmentUM.weight].
* @param modifier Modifier; should carry the overall size (e.g. `Modifier.size(240.dp)`).
* @param selectedIndex Index of the currently selected slice, or `null` for no selection (nothing dimmed).
* @param onSegmentClick Invoked on every tap inside the chart: with the tapped slice index, or with `null`
@ -86,7 +86,7 @@ import kotlin.math.min
@Suppress("MagicNumber", "LongParameterList", "LongMethod", "NamedArguments")
@Composable
internal fun DonutChart(
segments: List<DonutSegment>,
segments: List<DonutSegmentUM>,
modifier: Modifier = Modifier,
selectedIndex: Int? = null,
onSegmentClick: ((index: Int?) -> Unit)? = null,
@ -230,7 +230,7 @@ private fun segmentIndexAt(
tap: Offset,
size: Size,
strokePx: Float,
segments: List<DonutSegment>,
segments: List<DonutSegmentUM>,
startAngle: Float,
): Int? {
val cx = size.width / 2f
@ -351,10 +351,10 @@ private fun PreviewDonutChart() {
selectedIndex = selectedIndex,
onSegmentClick = { index -> selectedIndex = index.takeIf { it != selectedIndex } },
segments = listOf(
DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand),
DonutSegment(weight = 0.07f, color = TangemTheme.colors3.border.accent.violet),
DonutSegment(weight = 0.06f, color = TangemTheme.colors3.border.accent.red),
DonutSegment(weight = 0.05f, color = TangemTheme.colors3.border.accent.green),
DonutSegmentUM(weight = 0.55f, color = TangemTheme.colors3.border.brand),
DonutSegmentUM(weight = 0.07f, color = TangemTheme.colors3.border.accent.violet),
DonutSegmentUM(weight = 0.06f, color = TangemTheme.colors3.border.accent.red),
DonutSegmentUM(weight = 0.05f, color = TangemTheme.colors3.border.accent.green),
),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {

View file

@ -54,51 +54,49 @@ import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.pluralStringResourceSafe
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.foryou.impl.R
import com.tangem.features.foryou.impl.components.state.AiInsightState
import com.tangem.features.foryou.impl.components.state.DonutChartState
import com.tangem.features.foryou.impl.components.state.DonutSegment
import com.tangem.features.foryou.impl.components.state.MarketChartState
import com.tangem.features.foryou.impl.components.state.AiInsightUM
import com.tangem.features.foryou.impl.components.state.DonutChartUM
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@Composable
internal fun MarketChart(marketChartState: MarketChartState, modifier: Modifier = Modifier) {
val hazeState = LocalHazeState.current
internal fun MarketChart(modifier: Modifier = Modifier, marketChart: MarketChartUM) {
var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) }
TangemSurface(
modifier = modifier
.hazeSourceTangem(hazeState)
.hazeSourceTangem()
.onGloballyPositioned { cardBoundsInWindow = it.boundsInWindow() },
color = TangemTheme.colors3.bg.secondary,
) {
Column {
DonutChartBlock(marketChartState.donutChartState, cardBoundsInWindow)
DonutChartBlock(marketChart.donutChart, cardBoundsInWindow)
Spacer(modifier = Modifier.height(16.dp))
if (marketChartState is MarketChartState.Loaded) {
if (marketChart is MarketChartUM.Loaded) {
TopHoldingBlock(
assetCount = marketChartState.assetCount,
topHoldingPercent = marketChartState.topHoldingPercent,
assetCount = marketChart.assetCount,
topHoldingPercent = marketChart.topHoldingPercent,
)
} else {
CantLoadDataBlock()
}
Spacer(modifier = Modifier.height(16.dp))
AiInsightContent(marketChartState.aiInsightState)
AiInsightContent(marketChart.aiInsight)
}
}
}
@Suppress("LongMethod")
@Composable
private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBoundsInWindow: Rect) {
private fun ColumnScope.DonutChartBlock(donutChartUM: DonutChartUM, cardBoundsInWindow: Rect) {
var selectedIndex by remember { mutableStateOf<Int?>(null) }
val segments = donutChartState.donutSegmentList
val segments = donutChartUM.donutSegmentList
val scope = rememberCoroutineScope()
var dismissJob by remember { mutableStateOf<Job?>(null) }
var chartSize by remember { mutableStateOf(IntSize.Zero) }
@ -135,9 +133,9 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo
},
segments = segments,
) {
if (donutChartState is DonutChartState.Loaded) {
if (donutChartUM is DonutChartUM.Loaded) {
Text(
text = donutChartState.totalAmount,
text = donutChartUM.totalAmount,
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
@ -189,7 +187,7 @@ private fun ColumnScope.DonutChartBlock(donutChartState: DonutChartState, cardBo
@Composable
private fun DonutSegmentTooltipBlock(
selectedIndex: Int?,
segments: List<DonutSegment>,
segments: List<DonutSegmentUM>,
chartSize: IntSize,
chartWindowOffset: Offset,
cardBoundsInWindow: Rect,
@ -273,13 +271,13 @@ private fun ColumnScope.CantLoadDataBlock() {
// IntrinsicSize.Min lets the gradient divider match the AI text height — heightIn wouldn't achieve that.
@Suppress("ModifierHeightWithText")
@Composable
private fun AiInsightContent(aiInsightState: AiInsightState) {
private fun AiInsightContent(aiInsightUM: AiInsightUM) {
AnimatedContent(
targetState = aiInsightState,
targetState = aiInsightUM,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
) { currentState ->
when (currentState) {
is AiInsightState.AskAiInsight -> {
is AiInsightUM.AskAiInsight -> {
SecondaryTangemButton(
modifier = Modifier
.fillMaxWidth()
@ -289,7 +287,7 @@ private fun AiInsightContent(aiInsightState: AiInsightState) {
text = resourceReference(R.string.market_chart_ask_for_ai_summary_button),
)
}
is AiInsightState.Displayed -> {
is AiInsightUM.Displayed -> {
Row(
modifier = Modifier
.height(IntrinsicSize.Min)
@ -324,7 +322,7 @@ private fun AiInsightContent(aiInsightState: AiInsightState) {
)
}
}
AiInsightState.Hide -> {}
AiInsightUM.Hide -> {}
}
}
}
@ -350,7 +348,7 @@ private fun MarketChart_Preview(
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
) {
MarketChart(marketChartState = previewMarketChartState(scenario))
MarketChart(marketChart = previewMarketChartState(scenario))
}
}
}
@ -361,58 +359,58 @@ private fun MarketChart_Preview(
*/
@Suppress("MagicNumber")
@Composable
private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartState = when (scenario) {
MarketChartPreviewScenario.DISPLAYED -> MarketChartState.Loaded(
private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartUM = when (scenario) {
MarketChartPreviewScenario.DISPLAYED -> MarketChartUM.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.Displayed(
aiInsight = AiInsightUM.Displayed(
"Your portfolio leans on a single asset BTC is 42% of holdings. Stablecoins add 23% " +
"buffer. Consider trimming concentration for a smoother ride",
),
donutChartState = previewLoadedDonut(),
donutChart = previewLoadedDonut(),
)
MarketChartPreviewScenario.ASK_AI -> MarketChartState.Loaded(
MarketChartPreviewScenario.ASK_AI -> MarketChartUM.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.AskAiInsight(askAiInsightClick = {}),
donutChartState = previewLoadedDonut(),
aiInsight = AiInsightUM.AskAiInsight(askAiInsightClick = {}),
donutChart = previewLoadedDonut(),
)
MarketChartPreviewScenario.NO_AI -> MarketChartState.Loaded(
MarketChartPreviewScenario.NO_AI -> MarketChartUM.Loaded(
topHoldingPercent = 0.41f,
aiInsightState = AiInsightState.Hide,
donutChartState = DonutChartState.Loaded(
aiInsight = AiInsightUM.Hide,
donutChart = DonutChartUM.Loaded(
totalAmount = "$10,12345678912.1333",
donutSegmentList = listOf(
DonutSegment(weight = 0.55f, color = TangemTheme.colors3.border.brand),
DonutSegment(weight = 0.45f, color = TangemTheme.colors3.border.accent.green),
DonutSegmentUM(weight = 0.55f, color = TangemTheme.colors3.border.brand),
DonutSegmentUM(weight = 0.45f, color = TangemTheme.colors3.border.accent.green),
),
),
)
MarketChartPreviewScenario.NO_DATA -> MarketChartState.NoData
MarketChartPreviewScenario.NO_DATA -> MarketChartUM.NoData
}
@Suppress("MagicNumber")
@Composable
private fun previewLoadedDonut(): DonutChartState.Loaded = DonutChartState.Loaded(
private fun previewLoadedDonut(): DonutChartUM.Loaded = DonutChartUM.Loaded(
totalAmount = "$10,123456.1333",
donutSegmentList = listOf(
DonutSegment(
DonutSegmentUM(
weight = 0.55f,
color = TangemTheme.colors3.border.brand,
title = "Ethereum",
fiatValue = "$5,720.22",
),
DonutSegment(
DonutSegmentUM(
weight = 0.077f,
color = TangemTheme.colors3.border.accent.violet,
title = "Solana",
fiatValue = "$728.30",
),
DonutSegment(
DonutSegmentUM(
weight = 0.0666f,
color = TangemTheme.colors3.border.accent.red,
title = "Polkadot",
fiatValue = "$624.26",
),
DonutSegment(
DonutSegmentUM(
weight = 0.05f,
color = TangemTheme.colors3.border.accent.green,
title = "Tether",

View file

@ -7,7 +7,7 @@ import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.PopupPositionProvider
import com.tangem.features.foryou.impl.components.state.DonutSegment
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.roundToInt
@ -23,7 +23,7 @@ import kotlin.math.sin
@Suppress("MagicNumber", "LongParameterList", "ComplexCondition")
internal fun segmentTooltipPositionProvider(
selectedIndex: Int?,
segments: List<DonutSegment>,
segments: List<DonutSegmentUM>,
chartSize: IntSize,
chartWindowOffset: Offset,
strokePx: Float,

View file

@ -15,7 +15,7 @@ import androidx.compose.ui.graphics.Color
* @param fiatValue Pre-formatted fiat value of the slice (e.g. `"$5,720.22"`). Shown in the selection
* tooltip next to the share. Empty by default.
*/
internal data class DonutSegment(
internal data class DonutSegmentUM(
val weight: Float,
val color: Color,
val title: String = "",

View file

@ -1,40 +0,0 @@
package com.tangem.features.foryou.impl.components.state
internal sealed class MarketChartState(
open val donutChartState: DonutChartState,
open val aiInsightState: AiInsightState,
) {
data class Loaded(
override val donutChartState: DonutChartState.Loaded,
override val aiInsightState: AiInsightState = AiInsightState.Hide,
/* from 0 to 1 */
val topHoldingPercent: Float,
) : MarketChartState(
donutChartState = donutChartState,
aiInsightState = aiInsightState,
) {
val assetCount: Int = donutChartState.donutSegmentList.size
}
data object NoData : MarketChartState(
donutChartState = DonutChartState.NoData,
aiInsightState = AiInsightState.Hide,
)
}
internal sealed class DonutChartState(
open val donutSegmentList: List<DonutSegment>,
) {
data class Loaded(
val totalAmount: String,
override val donutSegmentList: List<DonutSegment>,
) : DonutChartState(donutSegmentList = donutSegmentList)
data object NoData : DonutChartState(donutSegmentList = emptyList())
}
internal sealed class AiInsightState {
data object Hide : AiInsightState()
data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightState()
data class Displayed(val text: String) : AiInsightState()
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.foryou.impl.components.state
import androidx.compose.runtime.Immutable
@Immutable
internal sealed class MarketChartUM(
open val donutChart: DonutChartUM,
open val aiInsight: AiInsightUM,
) {
data class Loaded(
override val donutChart: DonutChartUM.Loaded,
override val aiInsight: AiInsightUM = AiInsightUM.Hide,
/* from 0 to 1 */
val topHoldingPercent: Float,
) : MarketChartUM(
donutChart = donutChart,
aiInsight = aiInsight,
) {
val assetCount: Int = donutChart.donutSegmentList.size
}
data object NoData : MarketChartUM(
donutChart = DonutChartUM.NoData,
aiInsight = AiInsightUM.Hide,
)
}
@Immutable
internal sealed class DonutChartUM(
open val donutSegmentList: List<DonutSegmentUM>,
) {
data class Loaded(
val totalAmount: String,
override val donutSegmentList: List<DonutSegmentUM>,
) : DonutChartUM(donutSegmentList = donutSegmentList)
data object NoData : DonutChartUM(donutSegmentList = emptyList())
}
@Immutable
internal sealed class AiInsightUM {
data object Hide : AiInsightUM()
data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightUM()
data class Displayed(val text: String) : AiInsightUM()
}