diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index a1866b4db4..3efbbb2670 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -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> = diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 607cb93355..91e9077c31 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -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() + }, + ), ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 766575f273..0ea9fc518e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -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) } \ No newline at end of file diff --git a/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt index f9860f121a..3edc7a03c2 100644 --- a/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt +++ b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt @@ -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 + data class Params( + val callbacks: ForYouModelCallbacks, + ) + + interface ForYouModelCallbacks { + fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency) + } + + interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt index 4177db75fb..a6323a1230 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt @@ -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 } } \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt index 44d22d09f9..e6c1e4a31a 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutChart.kt @@ -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) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentSweeps.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentSweeps.kt new file mode 100644 index 0000000000..2c8854c856 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/DonutSegmentSweeps.kt @@ -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, + minFraction: Float = MIN_VISUAL_SWEEP_FRACTION, + capDeg: Float = 0f, +): List { + 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() + + // 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 +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index dd56102885..793cb72e7b 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -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"), diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt index bf03d8e6cf..65dd58b7fb 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/SegmentTooltipPositioning.kt @@ -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( diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt index 3a1b5f1bfd..9116c76ea9 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/ForYouModel.kt @@ -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() + private val expandedAssetIds = MutableStateFlow>(value = emptySet()) private val selectedAppCurrencyFlow: StateFlow = 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 diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt index e72a7e2d26..dabad495fa 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverter.kt @@ -39,10 +39,15 @@ internal class ForYouTokenListConverter( private val expandedAssetIds: Set, private val expandClick: (assetId: String) -> Unit, private val otherAssets: List, BigDecimal>>, + private val onTokenClick: (CryptoCurrency) -> Unit, ) : Converter, ImmutableList> { 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): ImmutableList { val assetItems = value diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt index c9ae8985eb..1a9f41f22a 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverter.kt @@ -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, ) } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt index 99874a142f..b07e6bc239 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformer.kt @@ -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, private val expandClick: (assetId: String) -> Unit, private val onPeriodClick: (TangemSegmentUM) -> Unit, + private val onTokenClick: (CryptoCurrency) -> Unit, ) : Transformer { 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( diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/components/DonutSegmentSweepsTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/components/DonutSegmentSweepsTest.kt new file mode 100644 index 0000000000..a9724b8840 --- /dev/null +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/components/DonutSegmentSweepsTest.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt index a484f7b7c7..8b5a850ce7 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/ForYouModelTest.kt @@ -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(), diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt index 4152f5b37b..97e1d86e69 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenListConverterTest.kt @@ -220,6 +220,7 @@ internal class ForYouTokenListConverterTest { expandedAssetIds = expandedAssetIds, expandClick = {}, otherAssets = otherAssets, + onTokenClick = {}, ) /** diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt index 9b9e46353f..01d8de1102 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/converter/ForYouTokenRowConverterTest.kt @@ -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. */ diff --git a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt index ad263cedb4..4e6e65f57a 100644 --- a/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt +++ b/features/for-you/impl/src/test/kotlin/com/tangem/features/foryou/impl/model/transformer/SetPortfolioReviewTransformerTest.kt @@ -288,6 +288,7 @@ internal class SetPortfolioReviewTransformerTest { expandedAssetIds = expandedAssetIds, expandClick = {}, onPeriodClick = {}, + onTokenClick = {}, ) private fun accountStatusList(