Updated on 2026-08-14
This commit is contained in:
parent
d792ac6304
commit
6de57d6545
18 changed files with 367 additions and 21 deletions
|
|
@ -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>> =
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ internal class ForYouTokenListConverterTest {
|
|||
expandedAssetIds = expandedAssetIds,
|
||||
expandClick = {},
|
||||
otherAssets = otherAssets,
|
||||
onTokenClick = {},
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ internal class SetPortfolioReviewTransformerTest {
|
|||
expandedAssetIds = expandedAssetIds,
|
||||
expandClick = {},
|
||||
onPeriodClick = {},
|
||||
onTokenClick = {},
|
||||
)
|
||||
|
||||
private fun accountStatusList(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue