Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 08:40:17 +02:00
parent d792ac6304
commit 6de57d6545
18 changed files with 367 additions and 21 deletions

View file

@ -19,6 +19,7 @@ import com.tangem.domain.markets.PreselectedMarketsInterval
import com.tangem.domain.markets.PreselectedMarketsOrder
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.earn.PreselectedEarnType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.components.earn.DefaultEarnComponent
import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent
@ -32,6 +33,7 @@ import com.tangem.features.feed.model.feed.FeedModelClickIntents
import com.tangem.features.feed.model.market.list.state.MarketsListUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.feed.ui.EntryContent
import com.tangem.features.foryou.TokenSummaryComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -146,6 +148,15 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
override fun openForYou() {
stackNavigation.bringToFront(FeedEntryChildFactory.Child.ForYou)
}
override fun openTokenSummary(userWalletId: UserWalletId, token: TokenSummaryComponent.Token) {
innerRouter.push(
FeedEntryChildFactory.Child.TokenSummary(
userWalletId = userWalletId,
token = token,
),
)
}
}
private val stack: Value<ChildStack<FeedEntryChildFactory.Child, ComposableModularBottomSheetContentComponent>> =

View file

@ -7,6 +7,8 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.features.feed.components.earn.DefaultEarnComponent
@ -21,6 +23,7 @@ import com.tangem.features.feed.components.news.list.DefaultNewsListComponent
import com.tangem.features.feed.components.search.DefaultSearchComponent
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.TokenSummaryComponent
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import kotlinx.serialization.Serializable
import javax.inject.Inject
@ -35,6 +38,7 @@ internal class FeedEntryChildFactory @Inject constructor(
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val designFeatureToggles: DesignFeatureToggles,
private val forYouComponentFactory: ForYouComponent.Factory,
private val tokenSummaryComponentFactory: TokenSummaryComponent.Factory,
) {
@Serializable
@ -72,6 +76,13 @@ internal class FeedEntryChildFactory @Inject constructor(
@Serializable
@Immutable
data object ForYou : Child
@Serializable
@Immutable
data class TokenSummary(
val userWalletId: UserWalletId,
val token: TokenSummaryComponent.Token,
) : Child
}
@Suppress("LongMethod")
@ -155,7 +166,26 @@ internal class FeedEntryChildFactory @Inject constructor(
)
Child.ForYou -> forYouComponentFactory.create(
context = appComponentContext,
params = Unit,
params = ForYouComponent.Params(
callbacks = object : ForYouComponent.ForYouModelCallbacks {
override fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) {
feedEntryClickIntents.openTokenSummary(
userWalletId = userWalletId,
token = TokenSummaryComponent.Token.Portfolio(currency),
)
}
},
),
)
is Child.TokenSummary -> tokenSummaryComponentFactory.create(
context = appComponentContext,
params = TokenSummaryComponent.Params(
userWalletId = child.userWalletId,
token = child.token,
callbacks = object : TokenSummaryComponent.TokenSummaryModelCallbacks {
override fun onDismiss() = onBackClicked()
},
),
)
}
}

View file

@ -3,8 +3,10 @@ package com.tangem.features.feed.model.feed
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
import com.tangem.features.foryou.TokenSummaryComponent
/**
* Callback interface for feed model navigation actions.
@ -33,4 +35,6 @@ internal interface FeedModelClickIntents {
fun openSearch(source: String)
fun openForYou()
fun openTokenSummary(userWalletId: UserWalletId, token: TokenSummaryComponent.Token)
}

View file

@ -2,8 +2,18 @@ package com.tangem.features.foryou
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
interface ForYouComponent : ComposableModularBottomSheetContentComponent {
interface Factory : ComponentFactory<Unit, ForYouComponent>
data class Params(
val callbacks: ForYouModelCallbacks,
)
interface ForYouModelCallbacks {
fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency)
}
interface Factory : ComponentFactory<Params, ForYouComponent>
}

View file

@ -35,11 +35,11 @@ import dagger.assisted.AssistedInject
internal class DefaultForYouComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Suppress("UnusedPrivateMember") @Assisted params: Unit,
@Assisted params: ForYouComponent.Params,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
) : AppComponentContext by context, ForYouComponent {
private val model: ForYouModel = getOrCreateModel()
private val model: ForYouModel = getOrCreateModel(params = params)
private val promoBannersBlockComponent: PromoBannersBlockComponent by lazy {
promoBannersBlockComponentFactory.create(
@ -94,6 +94,6 @@ internal class DefaultForYouComponent @AssistedInject constructor(
@AssistedFactory
interface Factory : ForYouComponent.Factory {
override fun create(context: AppComponentContext, params: Unit): DefaultForYouComponent
override fun create(context: AppComponentContext, params: ForYouComponent.Params): DefaultForYouComponent
}
}

View file

@ -114,10 +114,12 @@ internal fun DonutChart(
val clickModifier = if (onSegmentClick != null && segments.isNotEmpty()) {
Modifier.pointerInput(segments, startAngle, strokePx) {
detectTapGestures { tap ->
val clickedIndex = segmentIndexAt(tap, size.toSize(), strokePx, segments, startAngle)
if (latestSelectedIndex != clickedIndex) latestOnSegmentClick?.invoke(clickedIndex)
}
detectTapGestures(
onPress = { tap ->
val clickedIndex = segmentIndexAt(tap, size.toSize(), strokePx, segments, startAngle)
if (latestSelectedIndex != clickedIndex) latestOnSegmentClick?.invoke(clickedIndex)
},
)
}
} else {
Modifier
@ -164,8 +166,14 @@ internal fun DonutChart(
)
}
// Precompute each slice's [start, sweep] once.
val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f }
// Precompute each slice's [start, sweep] once. Sweeps are the *visual* angles: every
// non-zero slice is floored to a minimum share (see [visualSweepAngles]) so tiny holdings
// stay visible; larger slices shrink proportionally to make room. On a full ring the last
// slice's floor is bumped by the exact width its two lapped-over caps eat (see below).
val sweeps = visualSweepAngles(
weights = segments.map { it.weight.toFloat() },
capDeg = lastSegmentOverlapDeg(strokePx, arc.size.width),
)
val starts = sweeps.runningFold(startAngle) { acc, sweep -> acc + sweep }
// 2. Slices — reversed so slice 0 sits on top of its neighbor. Each slice gets its own
@ -248,7 +256,13 @@ private fun segmentIndexAt(
// Degrees clockwise from 3 o'clock — same convention as Canvas.drawArc.
val angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat().mod(360f)
val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f }
// Same cap compensation as the draw pass — centerline diameter is `min(size) - strokePx` (see
// [arcRect]) — so hit-testing matches the drawn geometry exactly.
val arcDiameter = min(size.width, size.height) - strokePx
val sweeps = visualSweepAngles(
weights = segments.map { it.weight.toFloat() },
capDeg = lastSegmentOverlapDeg(strokePx, arcDiameter),
)
val starts = sweeps.runningFold(startAngle) { acc, sweep -> acc + sweep }
for (i in segments.indices) {
if (sweeps[i] <= 0f) continue
@ -258,6 +272,19 @@ private fun segmentIndexAt(
return null
}
/**
* Exact extra sweep (degrees) the last slice needs on a full ring to read the same visible width as a
* middle slice (see [visualSweepAngles]).
*
* A round cap bulges past its arc's angular end by one cap radius (`strokePx / 2`), i.e.
* `capAngle = toDegrees((strokePx / 2) / R)` with `R = arcDiameter / 2` `toDegrees(strokePx / arcDiameter)`.
* A middle slice loses one such bulge at its start (covered by the previous slice's end cap) but keeps its
* own end cap, so its visible width equals its sweep. The last slice additionally has its end covered by
* slice 0's start cap at the wrap a second cap's worth so it needs `2 × capAngle` back.
*/
private fun lastSegmentOverlapDeg(strokePx: Float, arcDiameter: Float): Float =
2f * Math.toDegrees((strokePx / arcDiameter).toDouble()).toFloat()
/** Square arc bounds, centered in this [DrawScope], inset by half the stroke so the ring fits inside. */
private fun DrawScope.arcRect(strokePx: Float): ArcRect {
val diameter = min(size.width, size.height)

View file

@ -0,0 +1,86 @@
package com.tangem.features.foryou.impl.components
/** 7% of the full circle — the minimum visual share any non-zero segment is drawn at. */
internal const val MIN_VISUAL_SWEEP_FRACTION = 0.07f
private const val FULL_CIRCLE_DEG = 360f
/** Share of the round cap width the last segment is compensated for at its lapped-over seams. */
private const val LAST_SEGMENT_CAP_COMP_FACTOR = 0.75f
/**
* Maps normalized segment [weights] (each expected in `0f..1f`) to sweep angles in degrees, guaranteeing
* that every non-zero segment is drawn at least [minFraction] of the full circle (default 5% 18°), so a
* tiny holding never collapses into an invisible sliver.
*
* This is a purely **visual** transform: the returned angles drive the arc drawing, hit-testing, and the
* tooltip anchor. The real share shown in the tooltip must still come from the original `weight`.
*
* Rules:
* - Zero-weight segments always map to `0f` (the drawing / hit-test passes skip them).
* - Space for the bumped-up small segments is taken **proportionally** from the segments that are above the
* floor, so their relative proportions are preserved.
* - The total filled sweep (and therefore the unfilled track remainder) is kept unchanged whenever the
* floors fit inside it; it only grows into the track if the floors genuinely demand more room.
* - If there are so many segments that even the floor can't fit (`n * floor > 360°`), it falls back to an
* equal `360°/n` split.
* - [capDeg] compensates the round-cap squeeze on the **last segment only** (see [DonutChart] for the
* angle). The bump is `max(0, capDeg gap)`: full on a complete ring (where the last slice is lapped
* over at both seams), tapering as the unfilled track gap grows and reaching zero once the gap capDeg
* past that the last slice has a free end and is no worse off than a middle slice. Other slices lap over
* on one side and lose nothing net, so they're never bumped.
*
* The returned list has the same size and order as [weights].
*/
internal fun visualSweepAngles(
weights: List<Float>,
minFraction: Float = MIN_VISUAL_SWEEP_FRACTION,
capDeg: Float = 0f,
): List<Float> {
val base = weights.map { it.coerceIn(0f, 1f) * FULL_CIRCLE_DEG }
val activeIndices = base.indices.filter { base[it] > 0f }
val n = activeIndices.size
if (n == 0) return List(weights.size) { 0f }
val filledSum = activeIndices.sumOf { base[it].toDouble() }.toFloat()
// Never demand more than an equal share when the ring can't fit every floor.
val baseFloor = (minFraction * FULL_CIRCLE_DEG).coerceAtMost(FULL_CIRCLE_DEG / n)
// Compensation for the LAST segment only. On a full ring it's the one slice lapped-over by a round cap
// at both seams (its start by the previous slice's end cap, its end by slice 0's start cap), so it
// loses ~[capDeg] more visible width than the others. As a track gap opens, slice 0's start cap reaches
// its end less, so that extra loss shrinks linearly with the gap and hits zero once the gap ≥ capDeg —
// then the last slice is no worse off than a middle one, so no bump.
val gap = FULL_CIRCLE_DEG - filledSum
val comp = (capDeg * LAST_SEGMENT_CAP_COMP_FACTOR - gap).coerceAtLeast(0f)
val lastActive = activeIndices.last()
val floorOf = { index: Int ->
if (index == lastActive) (baseFloor + comp).coerceAtMost(FULL_CIRCLE_DEG / n) else baseFloor
}
// Preserve the filled sweep when the floors fit; otherwise grow just enough to satisfy them.
val floorsSum = activeIndices.sumOf { floorOf(it).toDouble() }.toFloat()
val budget = maxOf(filledSum, floorsSum).coerceAtMost(FULL_CIRCLE_DEG)
val result = MutableList(weights.size) { 0f }
val pinned = HashSet<Int>()
// Water-filling: repeatedly pin below-floor segments to their floor and re-split the rest
// proportionally, until no free segment falls below its floor. Converges in ≤ n iterations.
while (true) {
val freeIndices = activeIndices.filter { it !in pinned }
if (freeIndices.isEmpty()) {
pinned.forEach { result[it] = floorOf(it) }
break
}
val freeBudget = budget - pinned.sumOf { floorOf(it).toDouble() }.toFloat()
val freeBaseSum = freeIndices.sumOf { base[it].toDouble() }.toFloat()
freeIndices.forEach { result[it] = freeBudget * base[it] / freeBaseSum }
val newlyBelow = freeIndices.filter { result[it] < floorOf(it) }
if (newlyBelow.isEmpty()) {
pinned.forEach { result[it] = floorOf(it) }
break
}
pinned.addAll(newlyBelow)
}
return result
}

View file

@ -315,25 +315,25 @@ private fun previewLoadedDonut(): DonutChartUM.Loaded = DonutChartUM.Loaded(
totalAmount = "$10,123456.1333",
donutSegmentList = persistentListOf(
DonutSegmentUM(
weight = BigDecimal(0.55),
weight = BigDecimal(0.90),
color = DonutSegmentColor.Brand,
title = stringReference("Ethereum"),
fiatValue = stringReference("$5,720.22"),
),
DonutSegmentUM(
weight = BigDecimal(0.077),
weight = BigDecimal(0.03),
color = DonutSegmentColor.Violet,
title = stringReference("Solana"),
fiatValue = stringReference("$728.30"),
),
DonutSegmentUM(
weight = BigDecimal(0.0666),
weight = BigDecimal(0.03),
color = DonutSegmentColor.Red,
title = stringReference("Polkadot"),
fiatValue = stringReference("$624.26"),
),
DonutSegmentUM(
weight = BigDecimal(0.05),
weight = BigDecimal(0.02),
color = DonutSegmentColor.Green,
title = stringReference("Tether"),
fiatValue = stringReference("$520.18"),

View file

@ -41,8 +41,9 @@ internal fun segmentTooltipPositionProvider(
val centerX = chartSize.width / 2f
val centerY = chartSize.height / 2f
val innerRadius = diameter / 2f - strokePx / 2
// End angle of the selected slice (before its round cap) — same layout as DonutChart's drawing pass.
val sweeps = segments.map { it.weight.toFloat().coerceIn(0f, 1f) * 360f }
// End angle of the selected slice (before its round cap) — same *visual* layout as DonutChart's
// drawing pass, so the anchor lands on the (floored) slice end rather than its true-weight end.
val sweeps = visualSweepAngles(segments.map { it.weight.toFloat() })
val endAngleDeg = startAngle + sweeps.take(selectedIndex + 1).sum()
val endAngleRad = Math.toRadians(endAngleDeg.toDouble())
val anchorLocal = Offset(

View file

@ -4,12 +4,16 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
import com.tangem.features.foryou.impl.entity.ForYouUM
@ -25,12 +29,15 @@ import javax.inject.Inject
@Stable
@ModelScoped
internal class ForYouModel @Inject constructor(
paramsContainer: ParamsContainer,
userWalletsListRepository: UserWalletsListRepository,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
) : Model() {
private val params = paramsContainer.require<ForYouComponent.Params>()
private val expandedAssetIds = MutableStateFlow<Set<String>>(value = emptySet())
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
@ -65,13 +72,15 @@ internal class ForYouModel @Inject constructor(
flow3 = expandedAssetIds,
) { globalSelectedWallet, accountStatusList, expanded ->
// TODO For You add choose portfolio flow
val selectedWalletId = globalSelectedWallet?.walletId
uiState.update(
SetPortfolioReviewTransformer(
accountStatusList = accountStatusList[globalSelectedWallet?.walletId],
accountStatusList = accountStatusList[selectedWalletId],
appCurrency = selectedAppCurrencyFlow.value,
expandedAssetIds = expanded,
expandClick = ::onExpandClick,
onPeriodClick = ::onPeriodClick,
onTokenClick = { currency -> onTokenClick(selectedWalletId, currency) },
),
)
}
@ -89,6 +98,11 @@ internal class ForYouModel @Inject constructor(
)
}
private fun onTokenClick(selectedWalletId: UserWalletId?, currency: CryptoCurrency) {
val walletId = selectedWalletId ?: return
params.callbacks.onTokenClick(walletId, currency)
}
private fun onExpandClick(assetId: String) {
expandedAssetIds.update { ids ->
if (assetId in ids) ids - assetId else ids + assetId

View file

@ -39,10 +39,15 @@ internal class ForYouTokenListConverter(
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
private val onTokenClick: (CryptoCurrency) -> Unit,
) : Converter<List<CryptoCurrencyStatus>, ImmutableList<ForYouTokenListItemUM>> {
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val rowConverter = ForYouTokenRowConverter(appCurrency = appCurrency, totalFiatBalance = totalFiatBalance)
private val rowConverter = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = onTokenClick,
)
override fun convert(value: List<CryptoCurrencyStatus>): ImmutableList<ForYouTokenListItemUM> {
val assetItems = value

View file

@ -42,6 +42,7 @@ import java.math.BigDecimal
internal class ForYouTokenRowConverter(
private val appCurrency: AppCurrency,
private val totalFiatBalance: BigDecimal,
private val onTokenClick: (CryptoCurrency) -> Unit,
) {
private val iconConverter = CryptoCurrencyToIconStateConverter()
@ -65,7 +66,7 @@ internal class ForYouTokenRowConverter(
subtitleUM = toRowSubtitle(state, currency, cryptoAmount),
topEndContentUM = toRowTopEnd(state, fiatAmount),
bottomEndContentUM = toRowBottomEnd(state, fiatAmount),
onItemClick = null,
onItemClick = { onTokenClick(currency) },
onItemLongClick = null,
)
}

View file

@ -7,6 +7,7 @@ import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.foryou.impl.entity.ForYouUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.features.foryou.impl.model.ForYouNotification
@ -36,6 +37,7 @@ internal class SetPortfolioReviewTransformer(
private val expandedAssetIds: Set<String>,
private val expandClick: (assetId: String) -> Unit,
private val onPeriodClick: (TangemSegmentUM) -> Unit,
private val onTokenClick: (CryptoCurrency) -> Unit,
) : Transformer<ForYouUM> {
override fun transform(prevState: ForYouUM): ForYouUM {
@ -67,6 +69,7 @@ internal class SetPortfolioReviewTransformer(
expandedAssetIds = expandedAssetIds,
expandClick = expandClick,
otherAssets = otherAssets,
onTokenClick = onTokenClick,
).convert(topCurrencies)
val marketChartUM = ForYouMarketChartConverter(

View file

@ -0,0 +1,142 @@
package com.tangem.features.foryou.impl.components
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
internal class DonutSegmentSweepsTest {
@Test
fun `GIVEN empty weights WHEN visualSweepAngles THEN returns empty`() {
// Act
val actual = visualSweepAngles(emptyList())
// Assert
assertThat(actual).isEmpty()
}
@Test
fun `GIVEN all zero weights WHEN visualSweepAngles THEN all zero and size preserved`() {
// Act
val actual = visualSweepAngles(listOf(0f, 0f, 0f))
// Assert
assertThat(actual).containsExactly(0f, 0f, 0f).inOrder()
}
@Test
fun `GIVEN all segments above floor WHEN visualSweepAngles THEN sweeps stay proportional to weight`() {
// Arrange — 0.5 / 0.3 / 0.2, none below 5%.
val weights = listOf(0.5f, 0.3f, 0.2f)
// Act
val actual = visualSweepAngles(weights)
// Assert — untouched: weight * 360.
assertThat(actual[0]).isWithin(TOLERANCE).of(180f)
assertThat(actual[1]).isWithin(TOLERANCE).of(108f)
assertThat(actual[2]).isWithin(TOLERANCE).of(72f)
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
}
@Test
fun `GIVEN a segment below floor WHEN visualSweepAngles THEN it is raised to the floor and larger ones shrink`() {
// Arrange — only 0.05 is below the floor; filled sum is the whole circle.
val weights = listOf(0.8f, 0.15f, 0.05f)
// Act
val actual = visualSweepAngles(weights)
// Assert — the tiny slice is floored, the rest shrink to keep the sum at 360°.
assertThat(actual[2]).isWithin(TOLERANCE).of(FLOOR_DEG)
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
// Proportion between the two large slices is preserved (288 / 54 == actual[0] / actual[1]).
assertThat(actual[0] / actual[1]).isWithin(TOLERANCE).of(288f / 54f)
}
@Test
fun `GIVEN zero-weight slices among real ones WHEN visualSweepAngles THEN zeros stay zero`() {
// Arrange — a 0f slice sits between real ones.
val weights = listOf(0.9f, 0f, 0.08f, 0.02f)
// Act
val actual = visualSweepAngles(weights)
// Assert
assertThat(actual[1]).isEqualTo(0f)
assertThat(actual[3]).isWithin(TOLERANCE).of(FLOOR_DEG)
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
}
@Test
fun `GIVEN a single tiny segment WHEN visualSweepAngles THEN it grows into the track up to the floor`() {
// Arrange — 2% with no larger slice to borrow from; it must grow into the unfilled track.
val weights = listOf(0.02f)
// Act
val actual = visualSweepAngles(weights)
// Assert
assertThat(actual[0]).isWithin(TOLERANCE).of(FLOOR_DEG)
}
@Test
fun `GIVEN filled sum below the full circle and floors fit WHEN visualSweepAngles THEN filled sum preserved`() {
// Arrange — segments sum to 0.5 of the circle; the 0.03 slice is below the floor.
val weights = listOf(0.4f, 0.07f, 0.03f)
val filledSum = (0.4f + 0.07f + 0.03f) * 360f
// Act
val actual = visualSweepAngles(weights)
// Assert — small one floored, total filled sweep (track remainder) unchanged.
assertThat(actual[2]).isWithin(TOLERANCE).of(FLOOR_DEG)
assertThat(actual.sum()).isWithin(TOLERANCE).of(filledSum)
}
@Test
fun `GIVEN more segments than the floor allows WHEN visualSweepAngles THEN falls back to an equal split`() {
// Arrange — 25 equal slices; 25 floors would overflow 360°, so the floor drops to 360/25.
val weights = List(25) { 0.04f }
// Act
val actual = visualSweepAngles(weights)
// Assert
actual.forEach { assertThat(it).isWithin(TOLERANCE).of(360f / 25f) }
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
}
@Test
fun `GIVEN a full ring and capDeg WHEN visualSweepAngles THEN only the last floored slice is bumped`() {
// Arrange — two tiny slices below the floor on a full ring; index 2 is the last active.
val weights = listOf(0.9f, 0.05f, 0.05f)
// Act
val actual = visualSweepAngles(weights, capDeg = CAP_DEG)
// Assert — the non-last floored slice sits at the plain floor, the last one is bumped above it.
assertThat(actual[1]).isWithin(TOLERANCE).of(FLOOR_DEG)
assertThat(actual[2]).isGreaterThan(actual[1])
assertThat(actual.sum()).isWithin(TOLERANCE).of(360f)
}
@Test
fun `GIVEN a gap wider than capDeg WHEN visualSweepAngles THEN the last slice is not bumped`() {
// Arrange — filled sum well below the circle, so the gap far exceeds capDeg.
val weights = listOf(0.4f, 0.05f)
// Act
val actual = visualSweepAngles(weights, capDeg = CAP_DEG)
// Assert — no compensation: the last floored slice stays at the plain floor.
assertThat(actual[1]).isWithin(TOLERANCE).of(FLOOR_DEG)
}
private companion object {
const val TOLERANCE = 0.01f
const val CAP_DEG = 12f
// Derived from the production constant so these tests track it instead of hardcoding the angle.
const val FLOOR_DEG = MIN_VISUAL_SWEEP_FRACTION * 360f
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.foryou.impl.model
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
@ -15,6 +16,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.foryou.ForYouComponent
import com.tangem.features.foryou.impl.components.state.MarketChartUM
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -199,6 +201,13 @@ internal class ForYouModelTest {
private fun createModel(testScope: TestScope): ForYouModel {
return ForYouModel(
paramsContainer = MutableParamsContainer(
ForYouComponent.Params(
callbacks = object : ForYouComponent.ForYouModelCallbacks {
override fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) = Unit
},
),
),
userWalletsListRepository = userWalletsListRepository,
multiAccountStatusListSupplier = multiAccountStatusListSupplier,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),

View file

@ -220,6 +220,7 @@ internal class ForYouTokenListConverterTest {
expandedAssetIds = expandedAssetIds,
expandClick = {},
otherAssets = otherAssets,
onTokenClick = {},
)
/**

View file

@ -216,6 +216,7 @@ internal class ForYouTokenRowConverterTest {
private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter(
appCurrency = appCurrency,
totalFiatBalance = totalFiatBalance,
onTokenClick = {},
)
/** Mirrors the production fiat rendering used by [ForYouTokenRowConverter] for a resolved row. */

View file

@ -288,6 +288,7 @@ internal class SetPortfolioReviewTransformerTest {
expandedAssetIds = expandedAssetIds,
expandClick = {},
onPeriodClick = {},
onTokenClick = {},
)
private fun accountStatusList(