Updated on 2026-08-14
This commit is contained in:
commit
50fe9e603d
1919 changed files with 94940 additions and 15178 deletions
1
features/for-you/api/.gitignore
vendored
Normal file
1
features/for-you/api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
20
features/for-you/api/build.gradle.kts
Normal file
20
features/for-you/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
id("kotlin-parcelize")
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.features.foryou.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Project - Core */
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.ui)
|
||||
|
||||
/** Project - Domain */
|
||||
api(projects.domain.models)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
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 {
|
||||
|
||||
data class Params(
|
||||
val callbacks: ForYouModelCallbacks,
|
||||
)
|
||||
|
||||
interface ForYouModelCallbacks {
|
||||
fun onTokenClick(userWalletId: UserWalletId, currency: CryptoCurrency)
|
||||
}
|
||||
|
||||
interface Factory : ComponentFactory<Params, ForYouComponent>
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.features.foryou
|
||||
|
||||
interface ForYouFeatureToggles {
|
||||
val isForYouEnabled: Boolean
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
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
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
interface TokenSummaryComponent : ComposableModularBottomSheetContentComponent {
|
||||
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
val token: Token,
|
||||
val selectedTokenPeriodId: String? = null,
|
||||
val callbacks: TokenSummaryModelCallbacks,
|
||||
)
|
||||
|
||||
interface TokenSummaryModelCallbacks {
|
||||
fun onDismiss()
|
||||
}
|
||||
|
||||
/**
|
||||
* The token the summary is opened for. Has two shapes depending on the entry point:
|
||||
* - [Portfolio] — opened from a portfolio screen, where the full [CryptoCurrency] is available;
|
||||
* - [Market] — opened from a market-review screen, where there is no [CryptoCurrency] yet, only the
|
||||
* raw id and display data.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface Token {
|
||||
|
||||
@Serializable
|
||||
data class Portfolio(val cryptoCurrency: CryptoCurrency) : Token
|
||||
|
||||
@Serializable
|
||||
data class Market(
|
||||
val cryptoCurrencyRawId: CryptoCurrency.RawID,
|
||||
val title: String,
|
||||
val tangemIconUrl: String,
|
||||
) : Token
|
||||
}
|
||||
|
||||
interface Factory : ComponentFactory<Params, TokenSummaryComponent>
|
||||
}
|
||||
1
features/for-you/impl/.gitignore
vendored
Normal file
1
features/for-you/impl/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
73
features/for-you/impl/build.gradle.kts
Normal file
73
features/for-you/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.kotlin.serialization)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.features.foryou.impl"
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
merges += "paymentrequest.proto"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** Project - Features */
|
||||
api(projects.features.forYou.api)
|
||||
api(projects.features.promoBanners.api)
|
||||
implementation(projects.features.commonFeatures.api)
|
||||
|
||||
/** Project - Core */
|
||||
api(projects.core.configToggles)
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.utils)
|
||||
implementation(projects.core.ui)
|
||||
|
||||
/** Project - Common */
|
||||
api(projects.common.ui)
|
||||
implementation(projects.common.routing)
|
||||
|
||||
/** Project - Domain */
|
||||
api(projects.domain.account.status)
|
||||
api(projects.domain.appCurrency)
|
||||
api(projects.domain.common)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Project - Domain models */
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
|
||||
/** Compose */
|
||||
api(deps.compose.animation)
|
||||
api(deps.compose.foundation)
|
||||
implementation(deps.compose.material3)
|
||||
implementation(deps.compose.reorderable)
|
||||
implementation(deps.compose.ui)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
|
||||
/** Other libraries */
|
||||
implementation(deps.androidx.appCompat)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.decompose.ext.compose)
|
||||
implementation(deps.decompose)
|
||||
implementation(deps.haze)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.lifecycle.compose)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
/** Tests */
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.test.core)
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.tangem.features.foryou.impl
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.child
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
|
||||
import com.tangem.features.foryou.ForYouComponent
|
||||
import com.tangem.features.foryou.impl.model.ForYouModel
|
||||
import com.tangem.features.foryou.impl.ui.ForYouContent
|
||||
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultForYouComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted params: ForYouComponent.Params,
|
||||
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
|
||||
) : AppComponentContext by context, ForYouComponent {
|
||||
|
||||
private val model: ForYouModel = getOrCreateModel(params = params)
|
||||
|
||||
private val promoBannersBlockComponent: PromoBannersBlockComponent by lazy {
|
||||
promoBannersBlockComponentFactory.create(
|
||||
context = child("promoBannersBlockComponent"),
|
||||
params = PromoBannersBlockComponent.Params(
|
||||
placeholder = PromoBannersBlockComponent.Placeholder.FEED,
|
||||
isInitiallyVisibleOnScreen = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Title(bottomSheetState: State<BottomSheetState>) {
|
||||
TangemTopBar(
|
||||
title = resourceReference(R.string.for_you_title),
|
||||
type = TangemTopBarType.BottomSheet,
|
||||
startContent = {
|
||||
Icon(
|
||||
imageVector = Icons.ic_chevron_left_20,
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.primary,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.hazeEffectTangem { blurRadius = 8.dp }
|
||||
.clickableSingle(
|
||||
onClick = router::pop,
|
||||
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
|
||||
)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(
|
||||
bottomSheetState: State<BottomSheetState>,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val uiState by model.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
ForYouContent(
|
||||
forYouUM = uiState,
|
||||
bottomSheetState = bottomSheetState,
|
||||
promoBannersBlockComponent = promoBannersBlockComponent,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : ForYouComponent.Factory {
|
||||
override fun create(context: AppComponentContext, params: ForYouComponent.Params): DefaultForYouComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
package com.tangem.features.foryou.impl.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.BlurMaskFilter
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffXfermode
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.R
|
||||
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
|
||||
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.math.BigDecimal
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Ring (donut) chart drawn behind a center [content] slot.
|
||||
*
|
||||
* The ring is painted via [Modifier.drawBehind] so the center label stays a normal composable
|
||||
* ([content]) laid out on top — no manual text measuring inside the canvas.
|
||||
*
|
||||
* Paint order (bottom → top), all inside the canvas:
|
||||
* 1. The full-circle [trackColor] track (always drawn — it is the empty-state look when [segments] is empty),
|
||||
* with its own inner shadow.
|
||||
* 2. Each slice (reverse list order, so slice 0 ends up on top — its round cap tucks over the next one):
|
||||
* the solid stroke, then **its own** white inner shadow (Figma: X0 Y4 Blur8, white 24%) drawn right on
|
||||
* top of it. Per-slice (not one shadow over the whole ring) is what gives each pill its glossy, raised
|
||||
* look and the highlight at the colour seams. No colored glow/halo is used.
|
||||
*
|
||||
* Empty state: pass an empty [segments] list — only the track + its inner shadow render, and [content] can
|
||||
* show the "No data" label.
|
||||
*
|
||||
* Selection: when [selectedIndex] is non-null, everything except the chosen slice is dimmed with a
|
||||
* theme-adaptive overlay ([TangemTheme.colors3.border.inverse.tertiary]) — both the other slices and the
|
||||
* track (unfilled remainder) — so only the selected slice stays at full strength. Only one slice can be
|
||||
* selected at a time — selection is hoisted (the index is the slice's identity, as there are no segment ids
|
||||
* yet). Taps are hit-tested against the ring band only and reported via [onSegmentClick]; the chart is
|
||||
* interactive only when [onSegmentClick] is set **and** [segments] is non-empty.
|
||||
*
|
||||
* @param segments Slices, in priority order (index 0 is painted on top). See [DonutSegmentUM.weight].
|
||||
* @param modifier Modifier; should carry the overall size (e.g. `Modifier.size(240.dp)`).
|
||||
* @param selectedIndex Index of the currently selected slice, or `null` for no selection (nothing dimmed).
|
||||
* @param onSegmentClick Invoked on every tap inside the chart: with the tapped slice index, or with `null`
|
||||
* when the tap missed all slices (the center hole or the unfilled track). Passing `null` for the whole
|
||||
* callback makes the chart non-interactive. Toggling/switching/clearing the selection is the caller's
|
||||
* responsibility — e.g. map a repeat tap or a miss to deselection, and a tap on another slice to a switch.
|
||||
* @param strokeWidth Thickness of the ring.
|
||||
* @param trackColor Fill of the unfilled remainder of the circle (and the empty-state ring).
|
||||
* @param startAngle Angle (degrees) where the first slice starts. `-90f` = 12 o'clock.
|
||||
* @param content Centered content (e.g. total value + caption, or the "No data" label).
|
||||
*/
|
||||
@Suppress("MagicNumber", "LongParameterList", "LongMethod", "NamedArguments")
|
||||
@Composable
|
||||
internal fun DonutChart(
|
||||
segments: ImmutableList<DonutSegmentUM>,
|
||||
modifier: Modifier = Modifier,
|
||||
selectedIndex: Int? = null,
|
||||
onSegmentClick: ((index: Int?) -> Unit)? = null,
|
||||
strokeWidth: Dp = 28.dp,
|
||||
trackColor: Color = TangemTheme.colors3.border.tertiary,
|
||||
startAngle: Float = -90f,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val strokePx = with(LocalDensity.current) { strokeWidth.toPx() }
|
||||
val dimOverlayColor = TangemTheme.colors3.border.inverse.tertiary
|
||||
|
||||
// Resolve each slice's themed colour once, here in composition (the palette reads TangemTheme, which
|
||||
// isn't available inside the drawBehind DrawScope). Order is preserved 1:1 with [segments] so slice i
|
||||
// keeps the colour its producer assigned by rank — the draw pass below indexes by i, not by paint order.
|
||||
val segmentColors = segments.map { it.color.getColor() }
|
||||
|
||||
// Fade the dim in/out in step with the segment tooltip's pop-in (same spring as DonutSegmentTooltip).
|
||||
val dimProgress by animateFloatAsState(
|
||||
targetValue = if (selectedIndex != null) 1f else 0f,
|
||||
animationSpec = spring(dampingRatio = DIM_SPRING_DAMPING, stiffness = DIM_SPRING_STIFFNESS),
|
||||
label = "donutDim",
|
||||
)
|
||||
|
||||
var highlightedIndex by remember { mutableStateOf<Int?>(null) }
|
||||
if (selectedIndex != null) highlightedIndex = selectedIndex
|
||||
|
||||
val latestSelectedIndex by rememberUpdatedState(selectedIndex)
|
||||
val latestOnSegmentClick by rememberUpdatedState(onSegmentClick)
|
||||
|
||||
val clickModifier = if (onSegmentClick != null && segments.isNotEmpty()) {
|
||||
Modifier.pointerInput(segments, startAngle, strokePx) {
|
||||
detectTapGestures(
|
||||
onPress = { tap ->
|
||||
val clickedIndex = segmentIndexAt(tap, size.toSize(), strokePx, segments, startAngle)
|
||||
if (latestSelectedIndex != clickedIndex) latestOnSegmentClick?.invoke(clickedIndex)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(clickModifier)
|
||||
.drawBehind {
|
||||
val arc = arcRect(strokePx)
|
||||
// Inner shadow params from Figma: X0 Y4 Blur8 Spread0, white 24%.
|
||||
val innerDx = 0f
|
||||
val innerDy = 4.dp.toPx()
|
||||
val innerBlur = 8.dp.toPx()
|
||||
|
||||
// 1. Track — full circle behind everything, plus its inner shadow.
|
||||
drawArc(
|
||||
color = trackColor,
|
||||
startAngle = 0f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
topLeft = arc.topLeft,
|
||||
size = arc.size,
|
||||
style = Stroke(width = strokePx, cap = StrokeCap.Round),
|
||||
)
|
||||
drawInnerShadowArc(arc, 0f, 360f, strokePx, InnerShadowColor, innerBlur, innerDx, innerDy)
|
||||
|
||||
// Dim intensity animates 0f..1f; scale the overlay's own alpha by it so the dim fades.
|
||||
val dim = dimProgress.coerceIn(0f, 1f)
|
||||
val dimColor = dimOverlayColor.copy(alpha = dimOverlayColor.alpha * dim)
|
||||
|
||||
// Once a selection exists, dim the whole track too, so the unfilled remainder fades
|
||||
// along with the non-selected slices instead of staying bright. Drawn before the
|
||||
// slices, so each slice (selected included) paints on top at full strength.
|
||||
if (dim > 0f) {
|
||||
drawArc(
|
||||
color = dimColor,
|
||||
startAngle = 0f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
topLeft = arc.topLeft,
|
||||
size = arc.size,
|
||||
style = Stroke(width = strokePx, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
// inner shadow right after its fill, so the glossy highlight follows every pill (and
|
||||
// every colour seam), not just the ring's outer/inner contour.
|
||||
for (i in segments.indices.reversed()) {
|
||||
if (sweeps[i] <= 0f) continue
|
||||
drawArc(
|
||||
color = segmentColors[i],
|
||||
startAngle = starts[i],
|
||||
sweepAngle = sweeps[i],
|
||||
useCenter = false,
|
||||
topLeft = arc.topLeft,
|
||||
size = arc.size,
|
||||
style = Stroke(width = strokePx, cap = StrokeCap.Round),
|
||||
)
|
||||
drawInnerShadowArc(
|
||||
arc = arc,
|
||||
startAngle = starts[i],
|
||||
sweepAngle = sweeps[i],
|
||||
strokePx = strokePx,
|
||||
shadowColor = InnerShadowColor,
|
||||
blurPx = innerBlur,
|
||||
dx = innerDx,
|
||||
dy = innerDy,
|
||||
)
|
||||
|
||||
// Dim every non-highlighted slice while a selection is active — the chosen one stays
|
||||
// bright. Uses the animated [dim] so it fades, and [highlightedIndex] (not selectedIndex)
|
||||
// so the right slice stays bright through the fade-out after deselection.
|
||||
if (dim > 0f && i != highlightedIndex) {
|
||||
drawArc(
|
||||
color = dimColor,
|
||||
startAngle = starts[i],
|
||||
sweepAngle = sweeps[i],
|
||||
useCenter = false,
|
||||
topLeft = arc.topLeft,
|
||||
size = arc.size,
|
||||
style = Stroke(width = strokePx, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 36.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a tap [tap] to the index of the slice under it, or `null` if the tap is outside the ring band or
|
||||
* lands on a gap/track. Uses the same geometry as the drawing pass: the ring is centered, its outer radius
|
||||
* is half the min side and its inner radius is `outer - strokePx`. The angular test reuses the
|
||||
* `runningFold` start/sweep layout. A small radial tolerance makes the thin ring comfortable to hit.
|
||||
*/
|
||||
@Suppress("MagicNumber", "ReturnCount")
|
||||
private fun segmentIndexAt(
|
||||
tap: Offset,
|
||||
size: Size,
|
||||
strokePx: Float,
|
||||
segments: List<DonutSegmentUM>,
|
||||
startAngle: Float,
|
||||
): Int? {
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height / 2f
|
||||
val dx = tap.x - cx
|
||||
val dy = tap.y - cy
|
||||
|
||||
val outer = min(size.width, size.height) / 2f
|
||||
val inner = outer - strokePx
|
||||
val tolerance = strokePx * 0.4f
|
||||
val dist = hypot(dx, dy)
|
||||
if (dist < inner - tolerance || dist > outer + tolerance) return null
|
||||
|
||||
// Degrees clockwise from 3 o'clock — same convention as Canvas.drawArc.
|
||||
val angle = Math.toDegrees(atan2(dy, dx).toDouble()).toFloat().mod(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
|
||||
val relative = (angle - starts[i].mod(360f)).mod(360f)
|
||||
if (relative <= sweeps[i]) return i
|
||||
}
|
||||
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)
|
||||
val side = diameter - strokePx
|
||||
val left = (size.width - diameter) / 2f + strokePx / 2f
|
||||
val top = (size.height - diameter) / 2f + strokePx / 2f
|
||||
return ArcRect(topLeft = Offset(left, top), size = Size(side, side))
|
||||
}
|
||||
|
||||
private data class ArcRect(val topLeft: Offset, val size: Size)
|
||||
|
||||
/**
|
||||
* Draws an inset (inner) shadow confined to a single stroked arc — the canvas equivalent of CSS
|
||||
* `box-shadow: … inset`. Works on every API level (uses [BlurMaskFilter], not RenderEffect).
|
||||
*
|
||||
* Technique: in an isolated layer, paint the arc silhouette in [shadowColor], then "punch out" the same
|
||||
* arc offset by ([dx], [dy]) and blurred via [PorterDuff.Mode.DST_OUT]. What survives is a blurred band
|
||||
* of [shadowColor] hugging the edge opposite the offset — i.e. the inner shadow.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
private fun DrawScope.drawInnerShadowArc(
|
||||
arc: ArcRect,
|
||||
startAngle: Float,
|
||||
sweepAngle: Float,
|
||||
strokePx: Float,
|
||||
shadowColor: Color,
|
||||
blurPx: Float,
|
||||
dx: Float,
|
||||
dy: Float,
|
||||
) {
|
||||
if (sweepAngle <= 0f || blurPx <= 0f) return
|
||||
drawIntoCanvas { canvas ->
|
||||
val native = canvas.nativeCanvas
|
||||
val l = arc.topLeft.x
|
||||
val t = arc.topLeft.y
|
||||
val r = l + arc.size.width
|
||||
val b = t + arc.size.height
|
||||
|
||||
val layer = native.saveLayer(null, null)
|
||||
|
||||
// 1. The slice silhouette in the shadow color.
|
||||
val basePaint = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.STROKE
|
||||
strokeWidth = strokePx
|
||||
strokeCap = android.graphics.Paint.Cap.ROUND
|
||||
this.color = shadowColor.toArgb()
|
||||
}
|
||||
native.drawArc(l, t, r, b, startAngle, sweepAngle, false, basePaint)
|
||||
|
||||
// 2. Punch out an offset, blurred copy — leaves the shadow only along the inner edge.
|
||||
val cutPaint = android.graphics.Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = android.graphics.Paint.Style.STROKE
|
||||
strokeWidth = strokePx
|
||||
strokeCap = android.graphics.Paint.Cap.ROUND
|
||||
this.color = android.graphics.Color.BLACK
|
||||
xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
|
||||
maskFilter = BlurMaskFilter(blurPx, BlurMaskFilter.Blur.NORMAL)
|
||||
}
|
||||
native.drawArc(l + dx, t + dy, r + dx, b + dy, startAngle, sweepAngle, false, cutPaint)
|
||||
|
||||
native.restoreToCount(layer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Inner shadow — Figma #FFFFFF at 24% opacity (theme-independent). */
|
||||
private val InnerShadowColor = Color.White.copy(alpha = 0.24f)
|
||||
|
||||
// Selection-dim spring, mirroring DonutSegmentTooltip's pop-in so the dim and the tooltip move together.
|
||||
private const val DIM_SPRING_DAMPING = 0.82f
|
||||
private const val DIM_SPRING_STIFFNESS = 1100f
|
||||
|
||||
// region Previews
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Preview(name = "DonutChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "DonutChart • Light", showBackground = true)
|
||||
@Composable
|
||||
private fun PreviewDonutChart() {
|
||||
TangemThemePreviewRedesign {
|
||||
// Tap a slice to select it (others dim); tap it again to clear. Caller owns the selection.
|
||||
var selectedIndex by remember { mutableStateOf<Int?>(null) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DonutChart(
|
||||
modifier = Modifier.size(260.dp),
|
||||
selectedIndex = selectedIndex,
|
||||
onSegmentClick = { index -> selectedIndex = index.takeIf { it != selectedIndex } },
|
||||
segments = persistentListOf(
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.55),
|
||||
color = DonutSegmentColor.Brand,
|
||||
title = stringReference("Ethereum"),
|
||||
fiatValue = stringReference("$5,720.22"),
|
||||
),
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.07),
|
||||
color = DonutSegmentColor.Violet,
|
||||
title = stringReference("Solana"),
|
||||
fiatValue = stringReference("$728.30"),
|
||||
),
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.06),
|
||||
color = DonutSegmentColor.Red,
|
||||
title = stringReference("Polkadot"),
|
||||
fiatValue = stringReference("$624.26"),
|
||||
),
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.05),
|
||||
color = DonutSegmentColor.Green,
|
||||
title = stringReference("Tether"),
|
||||
fiatValue = stringReference("$520.18"),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "$10,000.1333",
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.medium,
|
||||
)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.market_chart_bubble_total_value),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "DonutChart Empty • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "DonutChart Empty • Light", showBackground = true)
|
||||
@Composable
|
||||
private fun PreviewDonutChartEmpty() {
|
||||
TangemThemePreviewRedesign {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DonutChart(
|
||||
modifier = Modifier.size(260.dp),
|
||||
segments = persistentListOf(),
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.market_chart_bubble_no_data),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
package com.tangem.features.foryou.impl.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import com.tangem.core.ui.ds2.surface.TangemSurface
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
||||
/**
|
||||
* Frosted-glass selection tooltip for a [DonutChart] slice.
|
||||
*
|
||||
* Modeled on [com.tangem.core.ui.ds.contextmenu.TangemContextMenu]: it shows a [Popup] with the same
|
||||
* springy pop-in animation, but renders the content on a translucent [TangemSurface] (`isMaterial = true`)
|
||||
* for the glass-morphism look instead of the context menu's opaque card. The pill is centered over the
|
||||
* chart's anchor (see [CenteredOverAnchorPositionProvider]).
|
||||
*
|
||||
* The popup is intentionally **non-focusable**, so its window is not modal: taps outside the pill pass
|
||||
* straight through to the [DonutChart] underneath, letting the user switch to another slice in a single
|
||||
* tap. `dismissOnClickOutside` stays on, so a tap anywhere off the pill — including outside the chart card,
|
||||
* elsewhere on the host screen — fires [onDismissRequest].
|
||||
*
|
||||
* Caveat the caller must handle: that outside-tap detection fires on the **DOWN**, before the chart's tap
|
||||
* resolves on the UP, and it also fires for taps that land on the chart. So [onDismissRequest] alone can't
|
||||
* tell "tapped the chart" from "tapped elsewhere" — the caller must veto/defer it when the press actually
|
||||
* landed on the chart (see `MarketChart`), otherwise selecting a slice would briefly clear the selection
|
||||
* first and flicker the popup.
|
||||
*
|
||||
* @param expanded Whether the tooltip is shown. Toggling to `false` plays the scale-out before the popup
|
||||
* leaves the composition.
|
||||
* @param title Asset name shown on the first line (e.g. `"Ethereum"`).
|
||||
* @param fiatValue Pre-formatted fiat value shown on the second line (e.g. `"$5,720.22"`).
|
||||
* @param percent Pre-formatted share shown after the value, dimmed (e.g. `"57.5%"`).
|
||||
* @param onDismissRequest Fired on a tap anywhere off the pill (DOWN). Clear the selection here, but see the
|
||||
* caveat above: defer it so a press on the chart can veto it before it commits.
|
||||
* @param modifier Modifier applied to the pill surface.
|
||||
* @param positionProvider Where the pill is placed. Defaults to centered over the anchor; pass a
|
||||
* [SegmentTooltipPositionProvider] to anchor it to the end of the selected slice.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
internal fun DonutSegmentTooltip(
|
||||
expanded: Boolean,
|
||||
title: TextReference,
|
||||
fiatValue: TextReference,
|
||||
percent: String,
|
||||
positionProvider: PopupPositionProvider,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val expandedStates = remember { MutableTransitionState(false) }
|
||||
expandedStates.targetState = expanded
|
||||
|
||||
if (expandedStates.currentState || expandedStates.targetState) {
|
||||
Popup(
|
||||
onDismissRequest = onDismissRequest,
|
||||
popupPositionProvider = positionProvider,
|
||||
properties = PopupProperties(focusable = false, dismissOnClickOutside = true),
|
||||
) {
|
||||
TooltipPill(
|
||||
expandedStates = expandedStates,
|
||||
title = title,
|
||||
fiatValue = fiatValue,
|
||||
percent = percent,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val OUT_TRANSITION_DURATION = 75
|
||||
private const val ENTER_SPRING_DAMPING = 0.82f
|
||||
private const val ENTER_SPRING_STIFFNESS = 1100f
|
||||
private const val DISMISSED_SCALE = 0.8f
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun TooltipPill(
|
||||
expandedStates: MutableTransitionState<Boolean>,
|
||||
title: TextReference,
|
||||
fiatValue: TextReference,
|
||||
percent: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val transition = rememberTransition(expandedStates, label = "DonutSegmentTooltip")
|
||||
|
||||
val scale by transition.animateFloat(
|
||||
transitionSpec = {
|
||||
if (false isTransitioningTo true) {
|
||||
spring(dampingRatio = ENTER_SPRING_DAMPING, stiffness = ENTER_SPRING_STIFFNESS)
|
||||
} else {
|
||||
tween(durationMillis = 1, delayMillis = OUT_TRANSITION_DURATION - 1)
|
||||
}
|
||||
},
|
||||
label = "scale",
|
||||
) { isExpanded -> if (isExpanded) 1f else DISMISSED_SCALE }
|
||||
|
||||
val alpha by transition.animateFloat(
|
||||
transitionSpec = {
|
||||
if (false isTransitioningTo true) {
|
||||
tween(durationMillis = 30)
|
||||
} else {
|
||||
tween(durationMillis = OUT_TRANSITION_DURATION)
|
||||
}
|
||||
},
|
||||
label = "alpha",
|
||||
) { isExpanded -> if (isExpanded) 1f else 0f }
|
||||
|
||||
TangemSurface(
|
||||
modifier = modifier.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
this.alpha = alpha
|
||||
transformOrigin = TransformOrigin.Center
|
||||
},
|
||||
isMaterial = true,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
shadowRadius = 10.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = fiatValue.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = " • $percent",
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Preview(name = "DonutSegmentTooltip • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "DonutSegmentTooltip • Light", showBackground = true)
|
||||
@Composable
|
||||
private fun PreviewDonutSegmentTooltip() {
|
||||
TangemThemePreviewRedesign {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.secondary)
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
TooltipPill(
|
||||
expandedStates = remember { MutableTransitionState(true) },
|
||||
title = stringReference("Ethereum"),
|
||||
fiatValue = stringReference("$5,720.22"),
|
||||
percent = "57.5%",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
package com.tangem.features.foryou.impl.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.BlurMaskFilter
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
||||
/**
|
||||
* One blurred color blob painted inside [CanvasGradientDivider].
|
||||
*
|
||||
* @param color Blob color.
|
||||
* @param offset Blob center, relative to the line's top-center.
|
||||
* @param height Blob height (its vertical diameter); the width is fixed by [DotWidth].
|
||||
* @param blur Mask-blur radius applied via [BlurMaskFilter].
|
||||
*/
|
||||
internal data class CanvasGlowDot(
|
||||
val color: Color,
|
||||
val offset: DpOffset,
|
||||
val height: Dp,
|
||||
val blur: Dp = 8.dp,
|
||||
)
|
||||
|
||||
/**
|
||||
* Canvas-based glow divider: the line background and every blurred color blob are painted by hand inside a
|
||||
* single `Box(Modifier.drawBehind { … })`, with no child composables. Inside [Modifier.drawBehind] we clip
|
||||
* to the capsule path, fill the base [LineColor], then draw each [CanvasGlowDot] as an oval whose native
|
||||
* [Paint] carries a [BlurMaskFilter] — the canvas equivalent of a Gaussian layer blur. The clip means a blob
|
||||
* drawn past the line's bounds only paints its color onto the visible capsule.
|
||||
*
|
||||
* @param modifier Modifier for positioning; the capsule height comes from the parent, so the glow scales
|
||||
* with the divider's length.
|
||||
* @param lineWidth Width of the capsule.
|
||||
*/
|
||||
@Suppress("MagicNumber", "LongParameterList", "LongMethod")
|
||||
@Composable
|
||||
internal fun CanvasGradientDivider(modifier: Modifier = Modifier, lineWidth: Dp = 2.dp) {
|
||||
val shape = RoundedCornerShape(size = 100.dp)
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "GlowDividerShadow")
|
||||
val shadowAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = GLOW_MIN_ALPHA,
|
||||
targetValue = GLOW_MAX_ALPHA,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1600, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "GlowDividerShadowAlpha",
|
||||
)
|
||||
|
||||
val accent = TangemTheme.colors3.icon.accent
|
||||
val dotColors = GlowDotColors(
|
||||
violet = accent.violet,
|
||||
green = accent.green,
|
||||
blue = accent.blue,
|
||||
orange = accent.orange,
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(lineWidth)
|
||||
.drawBehind {
|
||||
val resolvedDots = canvasDefaultDots(size.height.toDp(), dotColors)
|
||||
val cornerPx = size.width / 2f
|
||||
val capsule = Path().apply {
|
||||
addRoundRect(
|
||||
RoundRect(
|
||||
left = 0f,
|
||||
top = 0f,
|
||||
right = size.width,
|
||||
bottom = size.height,
|
||||
cornerRadius = CornerRadius(cornerPx, cornerPx),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
clipPath(capsule) {
|
||||
drawRect(color = LineColor)
|
||||
|
||||
// Blurred color blobs, drawn with a native BlurMaskFilter paint.
|
||||
drawIntoCanvas { canvas ->
|
||||
resolvedDots.forEach { dot ->
|
||||
val blurPx = dot.blur.toPx()
|
||||
val paint = Paint().apply {
|
||||
color = dot.color
|
||||
if (blurPx > 0f) {
|
||||
asFrameworkPaint().maskFilter =
|
||||
BlurMaskFilter(blurPx, BlurMaskFilter.Blur.NORMAL)
|
||||
}
|
||||
}
|
||||
|
||||
val ovalWidth = DotWidth.toPx()
|
||||
val ovalHeight = dot.height.toPx()
|
||||
val centerX = size.width / 2f + dot.offset.x.toPx()
|
||||
val centerY = dot.offset.y.toPx() + ovalHeight / 2f
|
||||
canvas.drawOval(
|
||||
left = centerX - ovalWidth / 2f,
|
||||
top = centerY - ovalHeight / 2f,
|
||||
right = centerX + ovalWidth / 2f,
|
||||
bottom = centerY + ovalHeight / 2f,
|
||||
paint = paint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.dropShadow(
|
||||
shape = shape,
|
||||
shadow = Shadow(
|
||||
radius = 12.dp,
|
||||
spread = 0.dp,
|
||||
color = TangemTheme.colors3.bg.brand.copy(alpha = shadowAlpha),
|
||||
),
|
||||
)
|
||||
.border(width = 0.5.dp, color = TangemTheme.colors3.border.secondary, shape = shape),
|
||||
)
|
||||
}
|
||||
|
||||
/** Glow pulse bounds for the animated drop shadow alpha. */
|
||||
private const val GLOW_MIN_ALPHA = 0.3f
|
||||
private const val GLOW_MAX_ALPHA = 0.65f
|
||||
|
||||
/** Line fill — Figma "Background color" #0000F9 at 56% opacity (alpha 0x8F). */
|
||||
private val LineColor = Color(0x8F0000F9)
|
||||
|
||||
/** Fixed blob width (the oval's horizontal diameter). */
|
||||
private val DotWidth = 12.dp
|
||||
|
||||
/** Accent colors for the glow blobs, resolved from `colors3.icon.accent.*` in the composable. */
|
||||
private data class GlowDotColors(
|
||||
val violet: Color,
|
||||
val green: Color,
|
||||
val blue: Color,
|
||||
val orange: Color,
|
||||
)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun canvasDefaultDots(lineHeight: Dp, colors: GlowDotColors): List<CanvasGlowDot> {
|
||||
val step = lineHeight / 5
|
||||
return listOf(
|
||||
CanvasGlowDot(colors.violet, DpOffset(x = 0.5.dp, y = (-3).dp), height = step), // purple
|
||||
CanvasGlowDot(colors.green, DpOffset(x = 4.dp, y = step * 1.7f), height = step), // green
|
||||
CanvasGlowDot(colors.blue, DpOffset(x = (-2.5).dp, y = step * 2.2f), height = step), // blue
|
||||
CanvasGlowDot(colors.orange, DpOffset(x = (-3.5).dp, y = step * 3), height = step + step / 2),
|
||||
CanvasGlowDot(colors.violet, DpOffset(x = 0.5.dp, y = step * 4 + 4.dp), height = step),
|
||||
)
|
||||
}
|
||||
|
||||
// region Previews
|
||||
|
||||
@Preview(name = "Canvas glow • Light", showBackground = true)
|
||||
@Preview(name = "Canvas glow • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewCanvasGradientDivider() {
|
||||
TangemThemePreviewRedesign {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(64.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Height comes from the parent — here a fixed 46.dp.
|
||||
CanvasGradientDivider(modifier = Modifier.height(46.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Length variants • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun PreviewCanvasLengthVariants() {
|
||||
TangemThemePreviewRedesign {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(64.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(48.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CanvasGradientDivider(modifier = Modifier.height(20.dp))
|
||||
CanvasGradientDivider(modifier = Modifier.height(46.dp))
|
||||
CanvasGradientDivider(modifier = Modifier.height(80.dp))
|
||||
CanvasGradientDivider(modifier = Modifier.height(140.dp))
|
||||
CanvasGradientDivider(modifier = Modifier.height(220.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
package com.tangem.features.foryou.impl.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.tangem.core.ui.components.SpacerH8
|
||||
import com.tangem.core.ui.components.haze.hazeSourceTangem
|
||||
import com.tangem.core.ui.ds2.surface.TangemSurface
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.R
|
||||
import com.tangem.features.foryou.impl.components.state.*
|
||||
import com.tangem.features.foryou.impl.ui.components.AiInsightContent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Composable
|
||||
internal fun MarketChart(marketChart: MarketChartUM, modifier: Modifier = Modifier) {
|
||||
var cardBoundsInWindow by remember { mutableStateOf(Rect.Zero) }
|
||||
|
||||
TangemSurface(
|
||||
modifier = modifier
|
||||
.hazeSourceTangem()
|
||||
.onGloballyPositioned { cardBoundsInWindow = it.boundsInWindow() },
|
||||
color = TangemTheme.colors3.bg.secondary,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
DonutChartBlock(marketChart.donutChart, cardBoundsInWindow)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (marketChart is MarketChartUM.Loaded) {
|
||||
TopHoldingBlock(
|
||||
assetCount = marketChart.assetCount,
|
||||
topHoldingPercent = marketChart.topHoldingPercent,
|
||||
)
|
||||
} else {
|
||||
CantLoadDataBlock()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (marketChart.aiInsight is AiInsightUM.Displayed) SpacerH8()
|
||||
|
||||
AiInsightContent(
|
||||
aiInsightUM = marketChart.aiInsight,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun ColumnScope.DonutChartBlock(donutChartUM: DonutChartUM, cardBoundsInWindow: Rect) {
|
||||
var selectedIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val segments = donutChartUM.donutSegmentList
|
||||
val scope = rememberCoroutineScope()
|
||||
var dismissJob by remember { mutableStateOf<Job?>(null) }
|
||||
var chartSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var chartWindowOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(32.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.size(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DonutChart(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
chartSize = coordinates.size
|
||||
chartWindowOffset = coordinates.localToWindow(Offset.Zero)
|
||||
}
|
||||
// A press on the chart means this tap is "on the chart", not "outside" — veto the pending
|
||||
// outside-dismiss before it commits.
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
dismissJob?.cancel()
|
||||
}
|
||||
},
|
||||
selectedIndex = selectedIndex,
|
||||
strokeWidth = DonutStrokeWidth,
|
||||
startAngle = DonutStartAngle,
|
||||
// Tap a slice → select; tap it again or miss (null) → deselect; tap another slice → switch.
|
||||
onSegmentClick = { index ->
|
||||
selectedIndex = index?.takeIf { it != selectedIndex }
|
||||
},
|
||||
segments = segments,
|
||||
) {
|
||||
if (donutChartUM is DonutChartUM.Loaded) {
|
||||
Text(
|
||||
text = donutChartUM.totalAmount,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
maxLines = 1,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
minFontSize = 8.sp,
|
||||
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.market_chart_bubble_total_value),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
maxLines = 1,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.market_chart_bubble_no_data),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
maxLines = 1,
|
||||
autoSize = TextAutoSize.StepBased(
|
||||
maxFontSize = TangemTheme.typography3.caption.medium.fontSize,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DonutSegmentTooltipBlock(
|
||||
selectedIndex = selectedIndex,
|
||||
segments = segments,
|
||||
chartSize = chartSize,
|
||||
chartWindowOffset = chartWindowOffset,
|
||||
cardBoundsInWindow = cardBoundsInWindow,
|
||||
onDismissRequest = {
|
||||
dismissJob?.cancel()
|
||||
dismissJob = scope.launch {
|
||||
withFrameNanos { }
|
||||
selectedIndex = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun DonutSegmentTooltipBlock(
|
||||
selectedIndex: Int?,
|
||||
segments: List<DonutSegmentUM>,
|
||||
chartSize: IntSize,
|
||||
chartWindowOffset: Offset,
|
||||
cardBoundsInWindow: Rect,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val gapPx = with(density) { 8.dp.roundToPx() }
|
||||
val strokePx = with(density) { DonutStrokeWidth.toPx() }
|
||||
var shownIndex by remember { mutableStateOf<Int?>(null) }
|
||||
if (selectedIndex != null) shownIndex = selectedIndex
|
||||
|
||||
val isExpanded = selectedIndex?.let(segments::getOrNull) != null
|
||||
val shownSegment = shownIndex?.let(segments::getOrNull) ?: return
|
||||
val positionProvider = remember(
|
||||
shownIndex,
|
||||
segments,
|
||||
chartSize,
|
||||
chartWindowOffset,
|
||||
cardBoundsInWindow,
|
||||
strokePx,
|
||||
gapPx,
|
||||
) {
|
||||
segmentTooltipPositionProvider(
|
||||
selectedIndex = shownIndex,
|
||||
segments = segments,
|
||||
chartSize = chartSize,
|
||||
chartWindowOffset = chartWindowOffset,
|
||||
strokePx = strokePx,
|
||||
startAngle = DonutStartAngle,
|
||||
cardBoundsInWindow = cardBoundsInWindow,
|
||||
gapPx = gapPx,
|
||||
)
|
||||
}
|
||||
|
||||
DonutSegmentTooltip(
|
||||
expanded = isExpanded,
|
||||
positionProvider = positionProvider,
|
||||
title = shownSegment.title,
|
||||
fiatValue = shownSegment.fiatValue,
|
||||
percent = shownSegment.weight.format { percent() },
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
private val DonutStrokeWidth = 28.dp
|
||||
private val DonutStartAngle = -90f
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.TopHoldingBlock(assetCount: Int, topHoldingPercent: TextReference) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = pluralStringResourceSafe(R.plurals.market_chart_assets_android, assetCount, assetCount),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = topHoldingPercent.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.CantLoadDataBlock() {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = stringResourceSafe(R.string.market_chart_can_not_load_data),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
}
|
||||
|
||||
// region Previews
|
||||
|
||||
private enum class MarketChartPreviewScenario { DISPLAYED, ASK_AI, NO_AI, NO_DATA }
|
||||
|
||||
private class MarketChartPreviewProvider : PreviewParameterProvider<MarketChartPreviewScenario> {
|
||||
override val values: Sequence<MarketChartPreviewScenario>
|
||||
get() = MarketChartPreviewScenario.entries.asSequence()
|
||||
}
|
||||
|
||||
@Preview(name = "MarketChart • Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(name = "MarketChart • Light", showBackground = true)
|
||||
@Composable
|
||||
private fun MarketChart_Preview(
|
||||
@PreviewParameter(MarketChartPreviewProvider::class) scenario: MarketChartPreviewScenario,
|
||||
) {
|
||||
TangemThemePreviewRedesign {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
MarketChart(marketChart = previewMarketChartState(scenario))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a [scenario] to the state shown in the preview. */
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun previewMarketChartState(scenario: MarketChartPreviewScenario): MarketChartUM = when (scenario) {
|
||||
MarketChartPreviewScenario.DISPLAYED -> MarketChartUM.Loaded(
|
||||
topHoldingPercent = stringReference("Top holding 41%"),
|
||||
aiInsight = AiInsightUM.Displayed(
|
||||
"Your portfolio leans on a single asset – BTC is 42% of holdings. Stablecoins add 23% " +
|
||||
"buffer. Consider trimming concentration for a smoother ride",
|
||||
),
|
||||
donutChart = previewLoadedDonut(),
|
||||
)
|
||||
MarketChartPreviewScenario.ASK_AI -> MarketChartUM.Loaded(
|
||||
topHoldingPercent = stringReference("Top holding 41%"),
|
||||
aiInsight = AiInsightUM.AskAiInsight(askAiInsightClick = {}),
|
||||
donutChart = previewLoadedDonut(),
|
||||
)
|
||||
MarketChartPreviewScenario.NO_AI -> MarketChartUM.Loaded(
|
||||
topHoldingPercent = stringReference("Top holding 41%"),
|
||||
aiInsight = AiInsightUM.Hide,
|
||||
donutChart = DonutChartUM.Loaded(
|
||||
totalAmount = "$10,12345678912.1333",
|
||||
donutSegmentList = persistentListOf(
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.55),
|
||||
color = DonutSegmentColor.Brand,
|
||||
title = stringReference("Ethereum"),
|
||||
fiatValue = stringReference("$5,720.22"),
|
||||
),
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.45),
|
||||
color = DonutSegmentColor.Green,
|
||||
title = stringReference("Solana"),
|
||||
fiatValue = stringReference("$728.30"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
MarketChartPreviewScenario.NO_DATA -> MarketChartUM.NoData
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun previewLoadedDonut(): DonutChartUM.Loaded = DonutChartUM.Loaded(
|
||||
totalAmount = "$10,123456.1333",
|
||||
donutSegmentList = persistentListOf(
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.90),
|
||||
color = DonutSegmentColor.Brand,
|
||||
title = stringReference("Ethereum"),
|
||||
fiatValue = stringReference("$5,720.22"),
|
||||
),
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.03),
|
||||
color = DonutSegmentColor.Violet,
|
||||
title = stringReference("Solana"),
|
||||
fiatValue = stringReference("$728.30"),
|
||||
),
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.03),
|
||||
color = DonutSegmentColor.Red,
|
||||
title = stringReference("Polkadot"),
|
||||
fiatValue = stringReference("$624.26"),
|
||||
),
|
||||
DonutSegmentUM(
|
||||
weight = BigDecimal(0.02),
|
||||
color = DonutSegmentColor.Green,
|
||||
title = stringReference("Tether"),
|
||||
fiatValue = stringReference("$520.18"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package com.tangem.features.foryou.impl.components
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Builds the [DonutSegmentTooltip] position provider anchored to the end of the selected slice. Returns the
|
||||
* centered fallback while the chart hasn't been measured yet or nothing is selected.
|
||||
*
|
||||
* @param startAngle Angle (degrees) where the first slice starts — must match the [DonutChart] drawing pass
|
||||
* (`-90f` = 12 o'clock) so the anchor lands on the real slice end.
|
||||
*/
|
||||
@Suppress("MagicNumber", "LongParameterList", "ComplexCondition")
|
||||
internal fun segmentTooltipPositionProvider(
|
||||
selectedIndex: Int?,
|
||||
segments: List<DonutSegmentUM>,
|
||||
chartSize: IntSize,
|
||||
chartWindowOffset: Offset,
|
||||
strokePx: Float,
|
||||
startAngle: Float,
|
||||
cardBoundsInWindow: Rect,
|
||||
gapPx: Int,
|
||||
): PopupPositionProvider {
|
||||
if (selectedIndex == null || selectedIndex !in segments.indices ||
|
||||
chartSize.width == 0 || chartSize.height == 0
|
||||
) {
|
||||
// Not shown in this state (selectedIndex is null / chart not measured) — position is irrelevant.
|
||||
return SegmentTooltipPositionProvider(Offset.Zero, Rect.Zero, gapPx)
|
||||
}
|
||||
val diameter = min(chartSize.width, chartSize.height).toFloat()
|
||||
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 *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(
|
||||
x = centerX + innerRadius * cos(endAngleRad).toFloat(),
|
||||
y = centerY + innerRadius * sin(endAngleRad).toFloat() - strokePx / 2,
|
||||
)
|
||||
|
||||
val anchorInWindow = chartWindowOffset + anchorLocal
|
||||
|
||||
return SegmentTooltipPositionProvider(
|
||||
anchorInWindow = anchorInWindow,
|
||||
cardBoundsInWindow = cardBoundsInWindow,
|
||||
gapPx = gapPx,
|
||||
strokePx = strokePx.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions the pill relative to the **end of the selected slice**, per the agreed spec.
|
||||
*
|
||||
* - **Base:** the pill's bottom-center sits [gapPx] above [anchorInWindow] (screen-up). [anchorInWindow] is
|
||||
* the slice-end point on the ring's inner edge, in window coordinates.
|
||||
* - **Card fallback:** if that placement would push the pill above the top of [cardBoundsInWindow] (the
|
||||
* `MarketChart` card), it flips to a side placement — the pill's start-center sits [gapPx] to the right
|
||||
* of the anchor.
|
||||
* - **Screen clamp:** the result is finally kept inside the window with a [gapPx] margin (shifted back by
|
||||
* however much it overflowed).
|
||||
*
|
||||
* @param anchorInWindow Slice-end / inner-edge point in window px.
|
||||
* @param cardBoundsInWindow `MarketChart` card bounds in window px (only the top edge gates the fallback).
|
||||
* @param gapPx The 8dp gap, in px.
|
||||
*/
|
||||
internal class SegmentTooltipPositionProvider(
|
||||
private val anchorInWindow: Offset,
|
||||
private val cardBoundsInWindow: Rect,
|
||||
private val gapPx: Int,
|
||||
private val strokePx: Int = 0,
|
||||
) : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize,
|
||||
): IntOffset {
|
||||
val w = popupContentSize.width
|
||||
val h = popupContentSize.height
|
||||
val ax = anchorInWindow.x.roundToInt()
|
||||
val ay = anchorInWindow.y.roundToInt()
|
||||
|
||||
var x = ax - w / 2
|
||||
var y = ay - h - gapPx
|
||||
|
||||
val isFlip = y < cardBoundsInWindow.top
|
||||
|
||||
if (isFlip) {
|
||||
x = ax + gapPx + strokePx / 2
|
||||
y = ay - h / 2 + strokePx / 2
|
||||
}
|
||||
|
||||
// Keep the pill inside the card on every edge, shifting it back by however much it overflows
|
||||
// (with a gap margin). The card sits within the screen, so this also keeps the pill on-screen.
|
||||
val minX = cardBoundsInWindow.left.roundToInt() + gapPx
|
||||
val minY = cardBoundsInWindow.top.roundToInt() + gapPx
|
||||
val maxX = (cardBoundsInWindow.right.roundToInt() - w - gapPx).coerceAtLeast(minX)
|
||||
val maxY = (cardBoundsInWindow.bottom.roundToInt() - h - gapPx).coerceAtLeast(minY)
|
||||
val clampedX = x.coerceIn(minX, maxX)
|
||||
val clampedY = y.coerceIn(minY, maxY)
|
||||
|
||||
return IntOffset(clampedX, clampedY)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.tangem.features.foryou.impl.components.state
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* One colored slice of a [com.tangem.features.foryou.impl.components.DonutChart].
|
||||
*
|
||||
* @param weight Fraction of the full circle this slice occupies, in `0f..1f`. The slices are laid out
|
||||
* contiguously; whatever is left after `sum(weight)` shows through as the track.
|
||||
* For a portfolio where slices sum to 1f the ring fills completely and no track is visible.
|
||||
* Also doubles as the slice's share for the selection tooltip (rendered as `weight * 100%`).
|
||||
* @param color Palette entry for the slice's solid fill. Assigned by the producer in segment order, so the
|
||||
* slice's colour follows its rank; [DonutChart] resolves it to a themed [Color] in composition.
|
||||
* @param title Human-readable name of the asset this slice represents (e.g. `"Ethereum"`). Shown in the
|
||||
* selection tooltip. Empty by default for slices that don't need a label.
|
||||
* @param fiatValue Pre-formatted fiat value of the slice (e.g. `"$5,720.22"`). Shown in the selection
|
||||
* tooltip next to the share. Empty by default.
|
||||
*/
|
||||
|
||||
@Immutable
|
||||
internal data class DonutSegmentUM(
|
||||
val color: DonutSegmentColor,
|
||||
val weight: BigDecimal,
|
||||
val title: TextReference,
|
||||
val fiatValue: TextReference,
|
||||
)
|
||||
|
||||
internal enum class DonutSegmentColor {
|
||||
Brand,
|
||||
Violet,
|
||||
Red,
|
||||
Green,
|
||||
;
|
||||
|
||||
@ReadOnlyComposable
|
||||
@Composable
|
||||
fun getColor(): Color {
|
||||
return when (this) {
|
||||
Brand -> TangemTheme.colors3.border.brand
|
||||
Violet -> TangemTheme.colors3.border.accent.violet
|
||||
Red -> TangemTheme.colors3.border.accent.red
|
||||
Green -> TangemTheme.colors3.border.accent.green
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.tangem.features.foryou.impl.components.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Immutable
|
||||
internal sealed class MarketChartUM(
|
||||
open val donutChart: DonutChartUM,
|
||||
open val aiInsight: AiInsightUM,
|
||||
) {
|
||||
data class Loaded(
|
||||
override val donutChart: DonutChartUM.Loaded,
|
||||
override val aiInsight: AiInsightUM = AiInsightUM.Hide,
|
||||
val topHoldingPercent: TextReference,
|
||||
) : MarketChartUM(
|
||||
donutChart = donutChart,
|
||||
aiInsight = aiInsight,
|
||||
) {
|
||||
val assetCount: Int = donutChart.donutSegmentList.size
|
||||
}
|
||||
|
||||
data object NoData : MarketChartUM(
|
||||
donutChart = DonutChartUM.NoData,
|
||||
aiInsight = AiInsightUM.Hide,
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal sealed class DonutChartUM(
|
||||
open val donutSegmentList: ImmutableList<DonutSegmentUM>,
|
||||
) {
|
||||
data class Loaded(
|
||||
val totalAmount: String,
|
||||
override val donutSegmentList: ImmutableList<DonutSegmentUM>,
|
||||
) : DonutChartUM(donutSegmentList = donutSegmentList)
|
||||
|
||||
data object NoData : DonutChartUM(donutSegmentList = persistentListOf())
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal sealed class AiInsightUM {
|
||||
data object Hide : AiInsightUM()
|
||||
data class AskAiInsight(val askAiInsightClick: () -> Unit) : AiInsightUM()
|
||||
data class Displayed(val text: String) : AiInsightUM()
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.tangem.features.foryou.impl.di
|
||||
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.foryou.ForYouComponent
|
||||
import com.tangem.features.foryou.ForYouFeatureToggles
|
||||
import com.tangem.features.foryou.impl.DefaultForYouComponent
|
||||
import com.tangem.features.foryou.impl.featuretoggles.DefaultForYouFeatureToggles
|
||||
import com.tangem.features.foryou.impl.model.ForYouModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ClassKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object ForYouFeatureModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideForYouFeatureToggles(featureTogglesManager: FeatureTogglesManager): ForYouFeatureToggles {
|
||||
return DefaultForYouFeatureToggles(featureTogglesManager = featureTogglesManager)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface ForYouComponentModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindForYouComponent(factory: DefaultForYouComponent.Factory): ForYouComponent.Factory
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(ForYouModel::class)
|
||||
fun bindForYouModel(impl: ForYouModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.tangem.features.foryou.impl.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
||||
import com.tangem.features.foryou.impl.model.ForYouNotification
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
internal data class ForYouUM(
|
||||
val portfolioReviewUM: PortfolioReviewUM,
|
||||
val notifications: ImmutableList<ForYouNotification>,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
internal sealed interface PortfolioReviewUM {
|
||||
val tokenList: ImmutableList<ForYouTokenListItemUM>
|
||||
val marketChartUM: MarketChartUM
|
||||
|
||||
data class Loading(
|
||||
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
|
||||
override val marketChartUM: MarketChartUM.NoData,
|
||||
) : PortfolioReviewUM
|
||||
|
||||
data class Content(
|
||||
override val tokenList: ImmutableList<ForYouTokenListItemUM>,
|
||||
override val marketChartUM: MarketChartUM,
|
||||
val periodPickerUM: TangemSegmentedPickerUM,
|
||||
val onPeriodClick: (TangemSegmentUM) -> Unit,
|
||||
) : PortfolioReviewUM
|
||||
}
|
||||
|
||||
@Immutable
|
||||
internal data class ForYouTokenListItemUM(
|
||||
val tokenRowUM: TangemTokenRowUM,
|
||||
val tokenList: ImmutableList<TangemTokenRowUM>,
|
||||
val isExpanded: Boolean,
|
||||
val isExpandable: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.features.foryou.impl.featuretoggles
|
||||
|
||||
import com.tangem.core.configtoggle.FeatureToggles
|
||||
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
|
||||
import com.tangem.features.foryou.ForYouFeatureToggles
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultForYouFeatureToggles @Inject constructor(
|
||||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
) : ForYouFeatureToggles {
|
||||
override val isForYouEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1469_FOR_YOU_ENABLED)
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package com.tangem.features.foryou.impl.model
|
||||
|
||||
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
|
||||
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
||||
import com.tangem.features.foryou.impl.model.transformer.SetPortfolioReviewTransformer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.transformer.update
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.*
|
||||
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()
|
||||
|
||||
val uiState: StateFlow<ForYouUM>
|
||||
field = MutableStateFlow<ForYouUM>(
|
||||
ForYouUM(
|
||||
notifications = persistentListOf(),
|
||||
portfolioReviewUM = PortfolioReviewUM.Loading(
|
||||
marketChartUM = MarketChartUM.NoData,
|
||||
tokenList = buildList<ForYouTokenListItemUM> {
|
||||
repeat(4) { index ->
|
||||
add(
|
||||
ForYouTokenListItemUM(
|
||||
tokenRowUM = TangemTokenRowUM.Loading(
|
||||
id = index.toString(),
|
||||
),
|
||||
tokenList = persistentListOf(),
|
||||
isExpanded = false,
|
||||
isExpandable = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.toPersistentList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
init {
|
||||
combine(
|
||||
flow = userWalletsListRepository.selectedUserWallet,
|
||||
flow2 = multiAccountStatusListSupplier.invokeAsMap(),
|
||||
flow3 = expandedAssetIds,
|
||||
) { globalSelectedWallet, accountStatusList, expanded ->
|
||||
// TODO For You add choose portfolio flow
|
||||
val selectedWalletId = globalSelectedWallet?.walletId
|
||||
uiState.update(
|
||||
SetPortfolioReviewTransformer(
|
||||
accountStatusList = accountStatusList[selectedWalletId],
|
||||
appCurrency = selectedAppCurrencyFlow.value,
|
||||
expandedAssetIds = expanded,
|
||||
expandClick = ::onExpandClick,
|
||||
onPeriodClick = ::onPeriodClick,
|
||||
onTokenClick = { currency -> onTokenClick(selectedWalletId, currency) },
|
||||
),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
|
||||
return getSelectedAppCurrencyUseCase().map { maybeAppCurrency ->
|
||||
maybeAppCurrency.getOrElse { AppCurrency.Default }
|
||||
}.stateIn(
|
||||
scope = modelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = AppCurrency.Default,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
|
||||
uiState.update { state ->
|
||||
state.copy(
|
||||
portfolioReviewUM = (state.portfolioReviewUM as? PortfolioReviewUM.Content)?.copy(
|
||||
periodPickerUM = state.portfolioReviewUM.periodPickerUM.copy(
|
||||
initialSelectedItem = tangemSegmentUM,
|
||||
),
|
||||
) ?: state.portfolioReviewUM,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.tangem.features.foryou.impl.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_cloud_exclamation_20
|
||||
import com.tangem.features.foryou.impl.R
|
||||
|
||||
@Immutable
|
||||
internal sealed class ForYouNotification(val state: TangemMessageBanner.State) {
|
||||
|
||||
data object UsedOutdatedData : ForYouNotification(
|
||||
state = TangemMessageBanner.State(
|
||||
title = resourceReference(R.string.warning_some_token_balances_not_updated),
|
||||
iconEnd = TangemIconUM.Icon(
|
||||
imageVector = Icons.ic_cloud_exclamation_20,
|
||||
tintReference = { TangemTheme.colors3.icon.primary },
|
||||
),
|
||||
variant = TangemMessageBanner.Variant.Warning,
|
||||
shouldShowGlowRing = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package com.tangem.features.foryou.impl.model.converter
|
||||
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.features.foryou.impl.components.state.*
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class ForYouMarketChartConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
private val topAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>>,
|
||||
) : Converter<TotalFiatBalance?, MarketChartUM> {
|
||||
override fun convert(value: TotalFiatBalance?): MarketChartUM {
|
||||
val topBalance = topAssets.sumOf { (_, assetBalance) -> assetBalance }
|
||||
return when (value) {
|
||||
is TotalFiatBalance.Loaded -> MarketChartUM.Loaded(
|
||||
donutChart = DonutChartUM.Loaded(
|
||||
totalAmount = value.amount.format {
|
||||
fiat(
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
)
|
||||
},
|
||||
donutSegmentList = topAssets.mapIndexed { index, (currencies, segmentBalance) ->
|
||||
val segmentWeight = segmentBalance.toForYouPercent(value.amount).orZero()
|
||||
DonutSegmentUM(
|
||||
color = DonutSegmentColor.entries.getOrNull(index) ?: DonutSegmentColor.Brand,
|
||||
weight = segmentWeight,
|
||||
title = stringReference(currencies.firstOrNull()?.currency?.name.orEmpty()),
|
||||
fiatValue = stringReference(segmentBalance.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}.toPersistentList(),
|
||||
),
|
||||
aiInsight = AiInsightUM.Hide,
|
||||
topHoldingPercent = stringReference(topBalance.toForYouPercent(value.amount).format { percent() }),
|
||||
)
|
||||
TotalFiatBalance.Loading,
|
||||
TotalFiatBalance.Failed,
|
||||
null,
|
||||
-> MarketChartUM.NoData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.tangem.features.foryou.impl.model.converter
|
||||
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.utils.extensions.isZero
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* Formatting helpers shared by the For You portfolio-review converters.
|
||||
*
|
||||
* Kept null-safe so that non-[CryptoCurrencyStatus.Loaded] states (which carry no fiat amount) degrade
|
||||
* to `null` instead of throwing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cross-network grouping key for the portfolio review: the same asset on different networks (e.g. USDC
|
||||
* on Solana and Ethereum) shares its `rawCurrencyId`, so they group under a single item. Custom tokens
|
||||
* have no raw id and fall back to their unique currency id, staying in their own group.
|
||||
*/
|
||||
internal fun CryptoCurrencyStatus.forYouGroupKey(): String = currency.id.rawCurrencyId?.value ?: currency.id.value
|
||||
|
||||
/**
|
||||
* Computes this fiat amount as a share of [totalFiatBalance]. Returns `null` when the share cannot be
|
||||
* computed (no amount, or a zero total / amount).
|
||||
*/
|
||||
internal fun BigDecimal?.toForYouPercent(totalFiatBalance: BigDecimal): BigDecimal? {
|
||||
if (this == null || totalFiatBalance.isZero() || isZero()) return null
|
||||
return divide(totalFiatBalance, RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
// TODO For You: replace this placeholder with the real price-change badge once the design is wired.
|
||||
internal fun forYouPlaceholderBadge(): TangemBadgeUM = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
size = TangemBadgeSize.X4,
|
||||
type = TangemBadgeType.Tinted,
|
||||
color = TangemBadgeColor.Green,
|
||||
)
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package com.tangem.features.foryou.impl.model.converter
|
||||
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.pluralReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.features.foryou.impl.R
|
||||
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Builds the For You portfolio-review list: groups the given currency statuses by asset across networks
|
||||
* (see [forYouGroupKey]) and maps each group to a [ForYouTokenListItemUM] — an aggregate asset row plus,
|
||||
* when the asset spans more than one network, its per-network child rows.
|
||||
*
|
||||
* The child rows are grouped by network (delegated to [ForYouTokenRowConverter]) so a network appears
|
||||
* once per asset even if the asset is held on it in several accounts;
|
||||
*
|
||||
* Modelled on `TokenListStateConverter` (a list converter delegating to a per-item converter).
|
||||
*/
|
||||
internal class ForYouTokenListConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
private val totalFiatBalance: BigDecimal,
|
||||
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,
|
||||
onTokenClick = onTokenClick,
|
||||
)
|
||||
|
||||
override fun convert(value: List<CryptoCurrencyStatus>): ImmutableList<ForYouTokenListItemUM> {
|
||||
val assetItems = value
|
||||
.groupBy { it.forYouGroupKey() }
|
||||
.map { (assetId, currencies) -> createListItem(assetId, currencies) }
|
||||
|
||||
// Assets beyond the top ones are collapsed into a single non-expandable "Other" row at the bottom.
|
||||
return if (otherAssets.count() > 0) {
|
||||
assetItems + createOtherItem()
|
||||
} else {
|
||||
assetItems
|
||||
}.toPersistentList()
|
||||
}
|
||||
|
||||
private fun createListItem(assetId: String, currencies: List<CryptoCurrencyStatus>): ForYouTokenListItemUM {
|
||||
// Group the asset's holdings by blockchain (network.id.rawId, derivation-independent) so each
|
||||
// network appears once even when the asset is held across several accounts/derivations on it,
|
||||
// summing those balances. Order by balance so the expanded breakdown reads top-down.
|
||||
val networkGroups = currencies
|
||||
.groupBy { it.currency.network.id.rawId }
|
||||
.values
|
||||
.sortedByDescending { group -> group.sumOf { it.value.fiatAmount.orZero() } }
|
||||
return ForYouTokenListItemUM(
|
||||
tokenRowUM = createAssetRow(
|
||||
assetId = assetId,
|
||||
currencies = currencies,
|
||||
networkCount = networkGroups.size,
|
||||
),
|
||||
tokenList = networkGroups.map(rowConverter::convertNetworkGroup).toPersistentList(),
|
||||
isExpanded = assetId in expandedAssetIds,
|
||||
isExpandable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAssetRow(
|
||||
assetId: String,
|
||||
currencies: List<CryptoCurrencyStatus>,
|
||||
networkCount: Int,
|
||||
): TangemTokenRowUM {
|
||||
if (currencies.all { it.value is CryptoCurrencyStatus.Loading }) {
|
||||
return TangemTokenRowUM.Loading(id = assetId)
|
||||
}
|
||||
|
||||
val asset = currencies.first()
|
||||
val assetFiatBalance = currencies.sumOf { it.value.fiatAmount.orZero() }
|
||||
|
||||
val endContent = rowConverter.toEndContent(statuses = currencies, fiatAmount = assetFiatBalance)
|
||||
|
||||
val onlyCryptoCurrency = currencies.firstOrNull()?.currency
|
||||
val isMain = onlyCryptoCurrency is CryptoCurrency.Coin
|
||||
|
||||
val subtitle = when {
|
||||
networkCount > 1 -> pluralReference(R.plurals.common_networks_count, networkCount)
|
||||
isMain -> resourceReference(R.string.common_main_network)
|
||||
onlyCryptoCurrency != null -> stringReference(onlyCryptoCurrency.network.standardType.name)
|
||||
else -> TextReference.EMPTY
|
||||
}
|
||||
|
||||
return TangemTokenRowUM.Content(
|
||||
id = assetId,
|
||||
headIconUM = TangemIconUM.Currency(iconConverter.convert(asset)),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(
|
||||
text = stringReference(asset.currency.symbol),
|
||||
badge = forYouPlaceholderBadge(),
|
||||
),
|
||||
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = subtitle,
|
||||
),
|
||||
topEndContentUM = endContent.top,
|
||||
bottomEndContentUM = endContent.bottom,
|
||||
onItemClick = { expandClick(assetId) },
|
||||
onItemLongClick = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createOtherItem(): ForYouTokenListItemUM {
|
||||
val otherAssetsBalance = otherAssets.sumOf { (_, assetBalance) -> assetBalance }
|
||||
return ForYouTokenListItemUM(
|
||||
tokenRowUM = TangemTokenRowUM.Content(
|
||||
id = OTHER_ROW_ID,
|
||||
headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(text = resourceReference(R.string.common_other)),
|
||||
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = pluralReference(R.plurals.market_chart_assets_android, otherAssets.count()),
|
||||
),
|
||||
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference(
|
||||
otherAssetsBalance.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference(otherAssetsBalance.toForYouPercent(totalFiatBalance).format { percent() }),
|
||||
),
|
||||
onItemClick = null,
|
||||
onItemLongClick = null,
|
||||
),
|
||||
tokenList = persistentListOf(),
|
||||
isExpanded = false,
|
||||
isExpandable = false,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val OTHER_ROW_ID = "for_you_other_assets"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
package com.tangem.features.foryou.impl.model.converter
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.styledResourceReference
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.utils.StringsSigns
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Builds a single per-network child row of an asset for the For You portfolio review.
|
||||
*
|
||||
* The input is all [CryptoCurrencyStatus]es of one asset on the *same* network (the asset may be held in
|
||||
* several accounts on that network). They are aggregated into one row — the crypto amount and fiat balance
|
||||
* are the per-network totals — so a network never appears twice within an asset's expanded breakdown.
|
||||
*
|
||||
* 1. all [CryptoCurrencyStatus.Loading] → a Loading row;
|
||||
* 2. any [CryptoCurrencyStatus.MissedDerivation] → "no address" treatment (missing address dominates —
|
||||
* the balance for that portion can't be trusted);
|
||||
* 3. any [CryptoCurrencyStatus.Unreachable] / [CryptoCurrencyStatus.NoAmount] → "unreachable" treatment;
|
||||
* 4. otherwise a normal content row summing the loaded/custom/no-quote/no-account amounts.
|
||||
*
|
||||
* [CryptoCurrencyStatus.Loading] entries inside an otherwise-resolved group are ignored for
|
||||
* classification (they contribute nothing yet). The cache/flicker indicators derive from the most
|
||||
* conservative [CryptoCurrencyStatus.Sources.total] across the contributing statuses.
|
||||
*/
|
||||
internal class ForYouTokenRowConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
private val totalFiatBalance: BigDecimal,
|
||||
private val onTokenClick: (CryptoCurrency) -> Unit,
|
||||
) {
|
||||
|
||||
private val iconConverter = CryptoCurrencyToIconStateConverter()
|
||||
|
||||
/** Builds one row for all [statuses] of a single asset on the same network. */
|
||||
fun convertNetworkGroup(statuses: List<CryptoCurrencyStatus>): TangemTokenRowUM {
|
||||
val representative = statuses.first()
|
||||
if (statuses.all { it.value is CryptoCurrencyStatus.Loading }) {
|
||||
return TangemTokenRowUM.Loading(id = representative.currency.id.value)
|
||||
}
|
||||
|
||||
val currency = representative.currency
|
||||
val cryptoAmount = statuses.sumOf { it.value.amount.orZero() }
|
||||
val fiatAmount = statuses.sumOf { it.value.fiatAmount.orZero() }
|
||||
val state = statuses.classify()
|
||||
|
||||
return TangemTokenRowUM.Content(
|
||||
id = currency.id.value,
|
||||
headIconUM = TangemIconUM.Currency(iconConverter.convert(representative)),
|
||||
titleUM = toRowTitle(currency),
|
||||
subtitleUM = toRowSubtitle(state, currency, cryptoAmount),
|
||||
topEndContentUM = toRowTopEnd(state, fiatAmount),
|
||||
bottomEndContentUM = toRowBottomEnd(state, fiatAmount),
|
||||
onItemClick = { onTokenClick(currency) },
|
||||
onItemLongClick = null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the aggregate status of [statuses] onto a row's top/bottom end content, rendering the given
|
||||
* pre-summed [fiatAmount]. Reflects the same cache-flicker / could-not-refresh / no-address /
|
||||
* unreachable treatment as [convertNetworkGroup], so the asset-level row surfaces the combined status
|
||||
* of its holdings — analogous to how `AccountCryptoPortfolioItemStateConverter` reflects a
|
||||
* `TotalFiatBalance`'s status on the account row.
|
||||
*
|
||||
* Callers must handle the all-[CryptoCurrencyStatus.Loading] case (a Loading row) before calling this.
|
||||
*/
|
||||
fun toEndContent(statuses: List<CryptoCurrencyStatus>, fiatAmount: BigDecimal): EndContent {
|
||||
val state = statuses.classify()
|
||||
return EndContent(
|
||||
top = toRowTopEnd(state, fiatAmount),
|
||||
bottom = toRowBottomEnd(state, fiatAmount),
|
||||
)
|
||||
}
|
||||
|
||||
/** Title: For You always shows the asset symbol with the placeholder price-change badge. */
|
||||
private fun toRowTitle(currency: CryptoCurrency): TangemTokenRowUM.TitleUM = TangemTokenRowUM.TitleUM.Content(
|
||||
text = stringReference(currency.symbol),
|
||||
badge = forYouPlaceholderBadge(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Subtitle: `network • amount` for resolved states, error messaging otherwise. Kept single-line to
|
||||
* match For You's style (no separate price-change line as in the wallet).
|
||||
*/
|
||||
private fun toRowSubtitle(
|
||||
state: RowState,
|
||||
currency: CryptoCurrency,
|
||||
cryptoAmount: BigDecimal,
|
||||
): TangemTokenRowUM.SubtitleUM = when (state) {
|
||||
is RowState.Normal -> TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = stringReference(
|
||||
"${currency.network.name} ${StringsSigns.DOT} ${
|
||||
cryptoAmount.format {
|
||||
crypto(
|
||||
cryptoCurrency = currency,
|
||||
)
|
||||
}
|
||||
}",
|
||||
),
|
||||
isFlickering = state.isFlickering,
|
||||
)
|
||||
RowState.NoAddress -> TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = stringReference("${currency.network.name} ${StringsSigns.DOT} ${StringsSigns.DASH_SIGN}"),
|
||||
)
|
||||
RowState.Unreachable -> TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = stringReference(currency.network.name),
|
||||
)
|
||||
}
|
||||
|
||||
/** Top-end: fiat total for resolved states, dash / unreachable treatment otherwise. */
|
||||
private fun toRowTopEnd(state: RowState, fiatAmount: BigDecimal): TangemTokenRowUM.EndContentUM = when (state) {
|
||||
is RowState.Normal -> TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference(
|
||||
fiatAmount.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
)
|
||||
},
|
||||
),
|
||||
isFlickering = state.isFlickering,
|
||||
startIcons = buildList {
|
||||
if (state.isOnlyCache) {
|
||||
add(
|
||||
TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_error_sync_default_24,
|
||||
tintReference = { TangemTheme.colors3.icon.tertiary },
|
||||
),
|
||||
)
|
||||
}
|
||||
}.toImmutableList(),
|
||||
)
|
||||
RowState.NoAddress,
|
||||
RowState.Unreachable,
|
||||
-> TangemTokenRowUM.EndContentUM.Content(text = stringReference(StringsSigns.DASH_SIGN))
|
||||
}
|
||||
|
||||
/** Bottom-end: percentage share for resolved states, no-address / unreachable treatment otherwise. */
|
||||
private fun toRowBottomEnd(state: RowState, fiatAmount: BigDecimal): TangemTokenRowUM.EndContentUM = when (state) {
|
||||
is RowState.Normal -> TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference(fiatAmount.toForYouPercent(totalFiatBalance).format { percent() }),
|
||||
isFlickering = state.isFlickering,
|
||||
)
|
||||
RowState.NoAddress -> attentionEndContent(R.string.common_no_address)
|
||||
RowState.Unreachable -> attentionEndContent(R.string.common_unreachable)
|
||||
}
|
||||
|
||||
private fun attentionEndContent(textRes: Int): TangemTokenRowUM.EndContentUM =
|
||||
TangemTokenRowUM.EndContentUM.Content(
|
||||
text = styledResourceReference(
|
||||
id = textRes,
|
||||
spanStyleReference = { SpanStyle(color = TangemTheme.colors3.text.status.warning) },
|
||||
),
|
||||
endIcons = persistentListOf(
|
||||
TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_attention_default_24,
|
||||
tintReference = { TangemTheme.colors3.icon.status.warning },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Collapses a mixed group into a single [RowState]. Loading-only groups are handled earlier, so a
|
||||
* group reaching here has at least one non-loading status. See the class KDoc for the priority rule.
|
||||
*/
|
||||
private fun List<CryptoCurrencyStatus>.classify(): RowState {
|
||||
val resolved = filterNot { it.value is CryptoCurrencyStatus.Loading }.map { it.value }
|
||||
return when {
|
||||
resolved.any { it is CryptoCurrencyStatus.MissedDerivation } -> RowState.NoAddress
|
||||
resolved.any {
|
||||
it is CryptoCurrencyStatus.Unreachable || it is CryptoCurrencyStatus.NoAmount
|
||||
} -> RowState.Unreachable
|
||||
else -> {
|
||||
val worstSource = resolved.map { it.sources.total }.worst()
|
||||
RowState.Normal(
|
||||
isFlickering = worstSource == StatusSource.CACHE,
|
||||
isOnlyCache = worstSource == StatusSource.ONLY_CACHE,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The most conservative status across the group: any [StatusSource.ONLY_CACHE] (could-not-refresh)
|
||||
* dominates a [StatusSource.CACHE] (still refreshing), which in turn dominates [StatusSource.ACTUAL].
|
||||
*/
|
||||
private fun List<StatusSource>.worst(): StatusSource = when {
|
||||
any { it == StatusSource.ONLY_CACHE } -> StatusSource.ONLY_CACHE
|
||||
any { it == StatusSource.CACHE } -> StatusSource.CACHE
|
||||
else -> StatusSource.ACTUAL
|
||||
}
|
||||
|
||||
/** The top and bottom end content of a token row, produced together from one classified group. */
|
||||
data class EndContent(
|
||||
val top: TangemTokenRowUM.EndContentUM,
|
||||
val bottom: TangemTokenRowUM.EndContentUM,
|
||||
)
|
||||
|
||||
/** Rendering-relevant collapse of the group's per-currency-status states. */
|
||||
private sealed interface RowState {
|
||||
/** Loaded / Custom / NoQuote / NoAccount — normal amounts, with cache/flicker indicators. */
|
||||
data class Normal(val isFlickering: Boolean, val isOnlyCache: Boolean) : RowState
|
||||
|
||||
/** At least one MissedDerivation — no blockchain address obtained. */
|
||||
data object NoAddress : RowState
|
||||
|
||||
/** At least one Unreachable / NoAmount — network could not be reached. */
|
||||
data object Unreachable : RowState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package com.tangem.features.foryou.impl.model.transformer
|
||||
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
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
|
||||
import com.tangem.features.foryou.impl.model.converter.ForYouMarketChartConverter
|
||||
import com.tangem.features.foryou.impl.model.converter.ForYouTokenListConverter
|
||||
import com.tangem.features.foryou.impl.model.converter.forYouGroupKey
|
||||
import com.tangem.utils.extensions.isZero
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Builds the [ForYouUM] state for the For You screen: the outdated-data notifications plus the portfolio
|
||||
* review (market chart, period picker and the grouped token list).
|
||||
*
|
||||
* The token list is delegated to [ForYouTokenListConverter] and the market chart to
|
||||
* [ForYouMarketChartConverter]; the period picker selection is carried over from the previous state so
|
||||
* it is not reset on every balance refresh.
|
||||
*
|
||||
* Modelled on `SetTokenListTransformer` (a transformer that rebuilds the state while delegating the
|
||||
* token-list construction to a dedicated converter).
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
internal class SetPortfolioReviewTransformer(
|
||||
private val accountStatusList: AccountStatusList?,
|
||||
private val appCurrency: AppCurrency,
|
||||
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 {
|
||||
val currencies = accountStatusList?.flattenCurrencies().orEmpty()
|
||||
val loadedBalance = accountStatusList?.totalFiatBalance as? TotalFiatBalance.Loaded
|
||||
val totalFiatBalance = loadedBalance?.amount.orZero()
|
||||
|
||||
// Drop only assets we positively know are empty — a resolved, priced zero fiat balance. Currencies
|
||||
// whose fiat we couldn't determine (unreachable / no-address / no-quote / still-loading — i.e. any
|
||||
// non-content status, which all carry a null fiatAmount) are kept so the converter can still render
|
||||
// them with the appropriate treatment instead of hiding a token the user actually holds.
|
||||
// Then aggregate the rest into assets (the same token across networks shares its forYouGroupKey)
|
||||
// and rank assets by their *summed* fiat balance.
|
||||
val rankedAssets = currencies
|
||||
.filterNot { it.value.fiatAmount?.isZero() == true }
|
||||
.groupBy { it.forYouGroupKey() }
|
||||
.map { (_, networks) -> networks to networks.sumOf { it.value.fiatAmount.orZero() } }
|
||||
.sortedByDescending { (_, assetBalance) -> assetBalance }
|
||||
|
||||
// The top assets are shown individually (each flattened back to its networks so the converter can
|
||||
// regroup them by network); the remaining assets are collapsed into a single "Other" row.
|
||||
val topAssets = rankedAssets.take(TOP_HOLDINGS_COUNT)
|
||||
val otherAssets = rankedAssets.drop(TOP_HOLDINGS_COUNT)
|
||||
val topCurrencies = topAssets.flatMap { (networks, _) -> networks }
|
||||
|
||||
val tokenList = ForYouTokenListConverter(
|
||||
appCurrency = appCurrency,
|
||||
totalFiatBalance = totalFiatBalance,
|
||||
expandedAssetIds = expandedAssetIds,
|
||||
expandClick = expandClick,
|
||||
otherAssets = otherAssets,
|
||||
onTokenClick = onTokenClick,
|
||||
).convert(topCurrencies)
|
||||
|
||||
val marketChartUM = ForYouMarketChartConverter(
|
||||
appCurrency = appCurrency,
|
||||
topAssets = topAssets,
|
||||
).convert(accountStatusList?.totalFiatBalance)
|
||||
|
||||
return prevState.copy(
|
||||
notifications = if (loadedBalance?.source == StatusSource.ONLY_CACHE) {
|
||||
persistentListOf(ForYouNotification.UsedOutdatedData)
|
||||
} else {
|
||||
persistentListOf()
|
||||
},
|
||||
portfolioReviewUM = PortfolioReviewUM.Content(
|
||||
periodPickerUM = when (prevState.portfolioReviewUM) {
|
||||
is PortfolioReviewUM.Content -> prevState.portfolioReviewUM.periodPickerUM
|
||||
is PortfolioReviewUM.Loading -> createPeriodPicker()
|
||||
},
|
||||
tokenList = tokenList,
|
||||
marketChartUM = marketChartUM,
|
||||
onPeriodClick = onPeriodClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPeriodPicker(): TangemSegmentedPickerUM {
|
||||
// TODO For you replace with data from backend
|
||||
val day = TangemSegmentUM(id = "0", title = stringReference("Day"))
|
||||
return TangemSegmentedPickerUM(
|
||||
items = persistentListOf(
|
||||
day,
|
||||
TangemSegmentUM(id = "1", title = stringReference("Week")),
|
||||
TangemSegmentUM(id = "2", title = stringReference("Month")),
|
||||
),
|
||||
initialSelectedItem = day,
|
||||
isFixed = true,
|
||||
isAltSurface = true,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOP_HOLDINGS_COUNT = 4
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import com.tangem.features.foryou.TokenSummaryComponent
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.InfoBottomSheetContent
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryBottomSheetConfig
|
||||
import com.tangem.features.foryou.impl.tokensummary.model.TokenSummaryModel
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.TokenSummaryContent
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.components.InfoBottomSheet
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.components.TokenSummaryTopNavigation
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultTokenSummaryComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted private val params: TokenSummaryComponent.Params,
|
||||
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
|
||||
) : TokenSummaryComponent, AppComponentContext by context {
|
||||
|
||||
private val model: TokenSummaryModel = getOrCreateModel(params = params)
|
||||
|
||||
private val bottomSheetSlot = childSlot(
|
||||
source = model.bottomSheetNavigation,
|
||||
serializer = TokenSummaryBottomSheetConfig.serializer(),
|
||||
handleBackButton = false,
|
||||
childFactory = { config, componentContext ->
|
||||
when (config) {
|
||||
TokenSummaryBottomSheetConfig.PortfolioSelector -> portfolioSelectorChild(componentContext)
|
||||
is TokenSummaryBottomSheetConfig.Info -> infoChild(config)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent =
|
||||
portfolioSelectorComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = PortfolioSelectorComponent.Params(
|
||||
portfolioFetcher = model.portfolioFetcher,
|
||||
controller = model.portfolioSelectorController,
|
||||
bsCallback = model.portfolioSelectorCallback,
|
||||
),
|
||||
)
|
||||
|
||||
private fun infoChild(config: TokenSummaryBottomSheetConfig.Info): ComposableBottomSheetComponent =
|
||||
object : ComposableBottomSheetComponent {
|
||||
override fun dismiss() = model.bottomSheetNavigation.dismiss()
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
InfoBottomSheet(
|
||||
infoBottomSheetContent = InfoBottomSheetContent(
|
||||
title = stringReference(config.indicatorType.title),
|
||||
body = stringReference("helps to estimate the token's momentum and market sentiment."),
|
||||
),
|
||||
onDismiss = ::dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Title(bottomSheetState: State<BottomSheetState>) {
|
||||
val uiState by model.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
TokenSummaryTopNavigation(
|
||||
header = uiState.header,
|
||||
onCloseClick = uiState.onCloseClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(
|
||||
bottomSheetState: State<BottomSheetState>,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val state by model.uiState.collectAsStateWithLifecycle()
|
||||
val bottomSheetSlot by bottomSheetSlot.subscribeAsState()
|
||||
|
||||
TokenSummaryContent(
|
||||
tokenSummary = state,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
bottomSheetSlot.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : TokenSummaryComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: TokenSummaryComponent.Params,
|
||||
): DefaultTokenSummaryComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.di
|
||||
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.foryou.TokenSummaryComponent
|
||||
import com.tangem.features.foryou.impl.tokensummary.DefaultTokenSummaryComponent
|
||||
import com.tangem.features.foryou.impl.tokensummary.model.TokenSummaryModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ClassKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface TokenSummaryComponentModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindTokenSummaryComponent(factory: DefaultTokenSummaryComponent.Factory): TokenSummaryComponent.Factory
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(TokenSummaryModel::class)
|
||||
fun bindTokenSummaryModel(impl: TokenSummaryModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
/**
|
||||
* Content of the informational bottom sheet shown when the user taps an indicator's info icon on the token
|
||||
* summary screen. Displays a [title] and an explanatory [body].
|
||||
*/
|
||||
internal data class InfoBottomSheetContent(
|
||||
val title: TextReference,
|
||||
val body: TextReference,
|
||||
) : TangemBottomSheetConfigContent
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
|
||||
@Immutable
|
||||
internal sealed interface PeriodPickerUM {
|
||||
|
||||
data class Content(val picker: TangemSegmentedPickerUM) : PeriodPickerUM
|
||||
|
||||
data object Loading : PeriodPickerUM
|
||||
|
||||
data object Empty : PeriodPickerUM
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
|
||||
@Immutable
|
||||
internal sealed interface TokenIndicatorUM {
|
||||
|
||||
val indicatorType: IndicatorType
|
||||
|
||||
data class Content(
|
||||
val sentimentBadge: TangemBadgeUM,
|
||||
val scoreBadge: TangemBadgeUM,
|
||||
override val indicatorType: IndicatorType,
|
||||
) : TokenIndicatorUM
|
||||
|
||||
data class NoData(override val indicatorType: IndicatorType) : TokenIndicatorUM
|
||||
|
||||
data class Loading(override val indicatorType: IndicatorType) : TokenIndicatorUM
|
||||
}
|
||||
|
||||
// TODO find out right source
|
||||
internal enum class IndicatorType(val title: String) {
|
||||
GalaxyScore("Galaxy score"),
|
||||
Sentiment("Sentiment"),
|
||||
RSI("RSI"),
|
||||
MACD("MACD"),
|
||||
MA_CROSS("MA Cross"),
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Immutable
|
||||
internal sealed class TokenSentimentUM {
|
||||
|
||||
abstract val indicators: ImmutableList<TokenIndicatorUM>
|
||||
|
||||
data class Content(
|
||||
val sentiment: TextReference,
|
||||
@param:IntRange(from = -5, to = 5)
|
||||
val totalScore: Int,
|
||||
val lastUpdate: TextReference,
|
||||
override val indicators: ImmutableList<TokenIndicatorUM>,
|
||||
) : TokenSentimentUM()
|
||||
|
||||
data object Empty : TokenSentimentUM() {
|
||||
override val indicators: ImmutableList<TokenIndicatorUM> = IndicatorType.entries
|
||||
.map { TokenIndicatorUM.NoData(indicatorType = it) }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
data object Loading : TokenSentimentUM() {
|
||||
override val indicators: ImmutableList<TokenIndicatorUM> = IndicatorType.entries
|
||||
.map { TokenIndicatorUM.Loading(indicatorType = it) }
|
||||
.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Navigation config for the single bottom-sheet slot hosted by the token summary component.
|
||||
*
|
||||
* Both sheets are mutually exclusive — the slot holds at most one child at a time.
|
||||
*/
|
||||
@Serializable
|
||||
internal sealed interface TokenSummaryBottomSheetConfig {
|
||||
|
||||
/** Portfolio selector shown before opening swap in multi-account mode. */
|
||||
@Serializable
|
||||
data object PortfolioSelector : TokenSummaryBottomSheetConfig
|
||||
|
||||
/** Informational sheet describing the tapped [indicatorType]. */
|
||||
@Serializable
|
||||
data class Info(val indicatorType: IndicatorType) : TokenSummaryBottomSheetConfig
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
internal data class TokenSummaryHeaderUM(
|
||||
val tangemIconUM: TangemIconUM,
|
||||
val title: TextReference,
|
||||
val subtitle: TextReference?,
|
||||
)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
|
||||
internal data class TokenSummaryUm(
|
||||
val header: TokenSummaryHeaderUM,
|
||||
val periodPicker: PeriodPickerUM,
|
||||
val aiInsight: AiInsightUM,
|
||||
val tokenSentiment: TokenSentimentUM,
|
||||
val onSwapClick: () -> Unit,
|
||||
val onPeriodClick: (TangemSegmentUM) -> Unit,
|
||||
val onInfoClick: (IndicatorType) -> Unit,
|
||||
val onCloseClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
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.R
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.account.status.producer.SingleAccountStatusProducer
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusSupplier
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
|
||||
import com.tangem.features.foryou.TokenSummaryComponent
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryBottomSheetConfig
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
|
||||
import com.tangem.features.foryou.impl.tokensummary.model.transformer.TokenSummaryTransformer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
import com.tangem.utils.transformer.update
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.Unit
|
||||
|
||||
@Stable
|
||||
@ModelScoped
|
||||
internal class TokenSummaryModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val appRouter: AppRouter,
|
||||
private val portfolioFetcherFactory: PortfolioFetcher.Factory,
|
||||
private val singleAccountStatusSupplier: SingleAccountStatusSupplier,
|
||||
val portfolioSelectorController: PortfolioSelectorController,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<TokenSummaryComponent.Params>()
|
||||
|
||||
private val iconConverter = CryptoCurrencyToIconStateConverter()
|
||||
private val swapNavigationJob = JobHolder()
|
||||
|
||||
private val selectedTokenPeriodId = MutableStateFlow(value = params.selectedTokenPeriodId)
|
||||
|
||||
/** Drives the single bottom-sheet slot hosted by the component (portfolio selector / info). */
|
||||
val bottomSheetNavigation: SlotNavigation<TokenSummaryBottomSheetConfig> = SlotNavigation()
|
||||
|
||||
/** Feeds the portfolio selector with the wallet's accounts. */
|
||||
val portfolioFetcher: PortfolioFetcher by lazy {
|
||||
portfolioFetcherFactory.create(
|
||||
mode = PortfolioFetcher.Mode.Wallet(params.userWalletId),
|
||||
scope = modelScope,
|
||||
)
|
||||
}
|
||||
|
||||
val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback {
|
||||
override val onDismiss: () -> Unit = { bottomSheetNavigation.dismiss() }
|
||||
override val onBack: () -> Unit = { bottomSheetNavigation.dismiss() }
|
||||
}
|
||||
|
||||
val uiState: StateFlow<TokenSummaryUm>
|
||||
field = MutableStateFlow<TokenSummaryUm>(buildInitialUiState())
|
||||
|
||||
init {
|
||||
selectedTokenPeriodId
|
||||
.onEach { periodId ->
|
||||
uiState.update(
|
||||
TokenSummaryTransformer(),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun buildInitialUiState(): TokenSummaryUm {
|
||||
return TokenSummaryUm(
|
||||
header = buildHeader(),
|
||||
periodPicker = PeriodPickerUM.Content(
|
||||
TangemSegmentedPickerUM(
|
||||
items = persistentListOf(
|
||||
TangemSegmentUM(id = "0", title = stringReference("Day")),
|
||||
TangemSegmentUM(id = "1", title = stringReference("Week")),
|
||||
TangemSegmentUM(id = "2", title = stringReference("Month")),
|
||||
),
|
||||
initialSelectedItem = null,
|
||||
isFixed = true,
|
||||
isAltSurface = true,
|
||||
),
|
||||
),
|
||||
tokenSentiment = TokenSentimentUM.Loading,
|
||||
aiInsight = AiInsightUM.Hide,
|
||||
onSwapClick = ::onSwapClicked,
|
||||
onPeriodClick = ::onPeriodClick,
|
||||
onInfoClick = ::onInfoClick,
|
||||
onCloseClick = params.callbacks::onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildHeader(): TokenSummaryHeaderUM = when (val token = params.token) {
|
||||
is TokenSummaryComponent.Token.Portfolio -> {
|
||||
val currency = token.cryptoCurrency
|
||||
TokenSummaryHeaderUM(
|
||||
tangemIconUM = TangemIconUM.Currency(iconConverter.convert(currency)),
|
||||
title = stringReference(currency.name.ifBlank { currency.symbol }),
|
||||
subtitle = stringReference(currency.network.name),
|
||||
)
|
||||
}
|
||||
is TokenSummaryComponent.Token.Market -> TokenSummaryHeaderUM(
|
||||
tangemIconUM = TangemIconUM.Url(url = token.tangemIconUrl, fallbackRes = R.drawable.ic_custom_token_44),
|
||||
title = stringReference(token.title),
|
||||
subtitle = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
|
||||
if (tangemSegmentUM.id == selectedTokenPeriodId.value) return
|
||||
|
||||
uiState.update {
|
||||
it.copy(tokenSentiment = TokenSentimentUM.Loading)
|
||||
}
|
||||
|
||||
selectedTokenPeriodId.value = tangemSegmentUM.id
|
||||
}
|
||||
|
||||
private fun onSwapClicked() {
|
||||
modelScope.launch {
|
||||
val account = if (portfolioSelectorController.isAccountModeSync()) {
|
||||
portfolioSelectorController.selectAccount(null)
|
||||
bottomSheetNavigation.activate(TokenSummaryBottomSheetConfig.PortfolioSelector)
|
||||
|
||||
val (_, selectedAccount) = portfolioSelectorController
|
||||
.selectedAccountWithData(portfolioFetcher)
|
||||
.filterNotNull()
|
||||
.first()
|
||||
|
||||
bottomSheetNavigation.dismiss()
|
||||
selectedAccount
|
||||
} else {
|
||||
singleAccountStatusSupplier(
|
||||
SingleAccountStatusProducer.Params(
|
||||
accountId = AccountId.forMainCryptoPortfolio(params.userWalletId),
|
||||
),
|
||||
)
|
||||
.filterIsInstance<AccountStatus.CryptoPortfolio>()
|
||||
.first()
|
||||
}
|
||||
|
||||
val currency = account.flattenCurrencies()
|
||||
.map(CryptoCurrencyStatus::currency)
|
||||
.firstOrNull(::matchesSummaryToken)
|
||||
|
||||
navigateToSwap(currency)
|
||||
}.saveIn(swapNavigationJob)
|
||||
}
|
||||
|
||||
private fun matchesSummaryToken(currency: CryptoCurrency): Boolean = when (val token = params.token) {
|
||||
is TokenSummaryComponent.Token.Portfolio -> {
|
||||
val summaryCurrency = token.cryptoCurrency
|
||||
currency.id.rawCurrencyId == summaryCurrency.id.rawCurrencyId && currency.network == summaryCurrency.network
|
||||
}
|
||||
is TokenSummaryComponent.Token.Market -> currency.id.rawCurrencyId == token.cryptoCurrencyRawId
|
||||
}
|
||||
|
||||
private fun navigateToSwap(currency: CryptoCurrency?) {
|
||||
appRouter.push(
|
||||
AppRoute.Swap(
|
||||
userWalletId = params.userWalletId,
|
||||
fromCryptoCurrency = currency,
|
||||
screenSource = "screen source", // TODO
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onInfoClick(indicatorType: IndicatorType) {
|
||||
bottomSheetNavigation.activate(TokenSummaryBottomSheetConfig.Info(indicatorType))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.model.transformer
|
||||
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeShape
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class TokenSummaryTransformer : Transformer<TokenSummaryUm> {
|
||||
|
||||
override fun transform(prevState: TokenSummaryUm): TokenSummaryUm {
|
||||
return prevState.copy(
|
||||
tokenSentiment = TokenSentimentUM.Content(
|
||||
sentiment = calculateSentiment(),
|
||||
lastUpdate = stringReference("Updated Jan 20 2026, 9:24 PM"), // TODO For You localization
|
||||
totalScore = -4,
|
||||
indicators = mapIndicators(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateSentiment(): TextReference {
|
||||
val outlook = "Negative"
|
||||
return stringReference("$outlook outlook") // TODO For You localization
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun mapIndicators() = persistentListOf(
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Neutral"),
|
||||
color = TangemBadgeColor.Blue,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.GalaxyScore,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
color = TangemBadgeColor.Green,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.Sentiment,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.RSI,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.MACD,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.MA_CROSS,
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,383 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.ui
|
||||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.SpacerH32
|
||||
import com.tangem.core.ui.components.SpacerH4
|
||||
import com.tangem.core.ui.components.SpacerH8
|
||||
import com.tangem.core.ui.ds.badge.TangemBadge
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeShape
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.preivew.previewContentSentiment
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.preivew.previewTokenSummary
|
||||
import com.tangem.features.foryou.impl.ui.components.AiInsightContent
|
||||
import com.tangem.features.foryou.impl.ui.components.GradientScaleBar
|
||||
import com.tangem.features.foryou.impl.ui.components.GradientScaleBarState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
internal fun TokenSummaryContent(
|
||||
tokenSummary: TokenSummaryUm,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var buttonHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = contentPadding.calculateTopPadding()),
|
||||
) {
|
||||
when (val periodPicker = tokenSummary.periodPicker) {
|
||||
is PeriodPickerUM.Content -> TangemSegmentedPicker(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
tangemSegmentedPickerUM = periodPicker.picker,
|
||||
onClick = tokenSummary.onPeriodClick,
|
||||
)
|
||||
PeriodPickerUM.Loading -> RectangleShimmer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.height(44.dp),
|
||||
radius = 12.dp,
|
||||
)
|
||||
PeriodPickerUM.Empty -> Unit
|
||||
}
|
||||
|
||||
SpacerH32()
|
||||
|
||||
when (val tokenSentiment = tokenSummary.tokenSentiment) {
|
||||
is TokenSentimentUM.Content -> SentimentsContent(
|
||||
tokenSentiment = tokenSentiment,
|
||||
aiInsight = tokenSummary.aiInsight,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
is TokenSentimentUM.Empty -> EmptySentimentContent(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
is TokenSentimentUM.Loading -> LoadingSentimentContent(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
IndicatorsList(
|
||||
indicators = tokenSummary.tokenSentiment.indicators,
|
||||
onInfoClick = tokenSummary.onInfoClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// Reserve space equal to the pinned button's full height
|
||||
Spacer(modifier = Modifier.height(buttonHeight))
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
text = stringResourceSafe(R.string.token_summary_go_to_swap_button),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } }
|
||||
.navigationBarsPadding()
|
||||
.padding(16.dp),
|
||||
onClick = tokenSummary.onSwapClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptySentimentContent(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.token_summary_can_not_load_token),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.NoData,
|
||||
modifier = Modifier.padding(vertical = 40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingSentimentContent(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.token_summary_title),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
SpacerH8()
|
||||
|
||||
RectangleShimmer(
|
||||
modifier = Modifier.size(width = 140.dp, height = 24.dp),
|
||||
)
|
||||
|
||||
SpacerH8()
|
||||
|
||||
RectangleShimmer(
|
||||
modifier = Modifier.size(width = 180.dp, height = 16.dp),
|
||||
)
|
||||
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.Loading,
|
||||
modifier = Modifier.padding(vertical = 40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SentimentsContent(
|
||||
tokenSentiment: TokenSentimentUM.Content,
|
||||
aiInsight: AiInsightUM,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.token_summary_title),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
SpacerH4()
|
||||
|
||||
Text(
|
||||
text = tokenSentiment.sentiment.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
|
||||
SpacerH4()
|
||||
|
||||
Text(
|
||||
text = tokenSentiment.lastUpdate.resolveReference(),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.Content(value = tokenSentiment.totalScore),
|
||||
modifier = Modifier.padding(vertical = 40.dp),
|
||||
)
|
||||
|
||||
if (aiInsight is AiInsightUM.Displayed) SpacerH8()
|
||||
|
||||
AiInsightContent(
|
||||
aiInsightUM = aiInsight,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IndicatorsList(
|
||||
indicators: ImmutableList<TokenIndicatorUM>,
|
||||
onInfoClick: (IndicatorType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
indicators.forEach { indicator ->
|
||||
IndicatorRow(
|
||||
indicator = indicator,
|
||||
onInfoClick = { onInfoClick(indicator.indicatorType) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IndicatorRow(indicator: TokenIndicatorUM, onInfoClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
TangemRow(
|
||||
modifier = modifier,
|
||||
divider = true,
|
||||
includeInnerPaddings = true,
|
||||
contentLead = TangemRowContentLead.Equal,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
titleSlot = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickableSingle(onClick = onInfoClick)
|
||||
.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = indicator.indicatorType.title,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier.size(16.dp),
|
||||
painter = painterResource(id = R.drawable.ic_information_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
)
|
||||
}
|
||||
},
|
||||
valueSlot = {
|
||||
when (indicator) {
|
||||
is TokenIndicatorUM.Content -> {
|
||||
TangemBadge(badgeUM = indicator.scoreBadge)
|
||||
TangemBadge(badgeUM = indicator.sentimentBadge)
|
||||
}
|
||||
is TokenIndicatorUM.Loading -> {
|
||||
RectangleShimmer(
|
||||
modifier = Modifier.size(width = 48.dp, height = 24.dp),
|
||||
radius = 12.dp,
|
||||
)
|
||||
}
|
||||
is TokenIndicatorUM.NoData -> {
|
||||
TangemBadge(
|
||||
badgeUM = TangemBadgeUM(
|
||||
text = stringReference("None"), // TODO For You localization
|
||||
size = TangemBadgeSize.X6,
|
||||
color = TangemBadgeColor.Gray,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
|
||||
@Preview(name = "Light", showBackground = true, widthDp = 360)
|
||||
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun TokenSummaryContentPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
TokenSummaryContent(
|
||||
tokenSummary = previewTokenSummary(
|
||||
periodPickerUm = PeriodPickerUM.Content(
|
||||
TangemSegmentedPickerUM(
|
||||
items = persistentListOf(
|
||||
TangemSegmentUM(id = "0", title = stringReference("Day")),
|
||||
TangemSegmentUM(id = "1", title = stringReference("Week")),
|
||||
TangemSegmentUM(id = "2", title = stringReference("Month")),
|
||||
),
|
||||
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
|
||||
isFixed = true,
|
||||
isAltSurface = true,
|
||||
),
|
||||
),
|
||||
tokenSentiment = previewContentSentiment,
|
||||
),
|
||||
contentPadding = PaddingValues.Zero,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Loading · Light", showBackground = true, widthDp = 360)
|
||||
@Preview(name = "Loading · Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun TokenSummaryContentLoadingPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
TokenSummaryContent(
|
||||
tokenSummary = previewTokenSummary(
|
||||
periodPickerUm = PeriodPickerUM.Loading,
|
||||
tokenSentiment = TokenSentimentUM.Loading,
|
||||
),
|
||||
contentPadding = PaddingValues.Zero,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Empty · Light", showBackground = true, widthDp = 360)
|
||||
@Preview(name = "Empty · Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun TokenSummaryContentEmptyPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
TokenSummaryContent(
|
||||
tokenSummary = previewTokenSummary(
|
||||
periodPickerUm = PeriodPickerUM.Empty,
|
||||
tokenSentiment = TokenSentimentUM.Empty,
|
||||
),
|
||||
contentPadding = PaddingValues.Zero,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation.ContentAlign
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.InfoBottomSheetContent
|
||||
|
||||
/**
|
||||
* Informational modal bottom sheet for the token summary screen.
|
||||
*
|
||||
* Renders the [infoBottomSheetContent]: a [title][InfoBottomSheetContent.title] with a trailing close (`✕`) button in the top
|
||||
* navigation, and a scrollable explanatory [body][InfoBottomSheetContent.body]. Visibility is driven by the hosting
|
||||
* Decompose slot, so the config is always shown; [onDismiss] delegates back to the slot navigation.
|
||||
*
|
||||
* @param infoBottomSheetContent the info content to display.
|
||||
* @param onDismiss invoked when the sheet is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InfoBottomSheet(infoBottomSheetContent: InfoBottomSheetContent, onDismiss: () -> Unit) {
|
||||
TangemBottomSheet<InfoBottomSheetContent>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = onDismiss,
|
||||
content = infoBottomSheetContent,
|
||||
),
|
||||
type = TangemBottomSheetType.Modal,
|
||||
containerColor = TangemTheme.colors3.bg.primary,
|
||||
title = { content ->
|
||||
TangemTopNavigation(
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
contentAlign = ContentAlign.Center,
|
||||
endButton = { TangemButton.Close(onClick = onDismiss) },
|
||||
contentColumn = {
|
||||
Text(
|
||||
text = content.title.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { content ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(all = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = content.body.resolveReference(),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.ui.components
|
||||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
|
||||
|
||||
/**
|
||||
* Top navigation ("Nav bar") for the token summary screen.
|
||||
*
|
||||
* Built on the redesigned [TangemTopNavigation]: a leading [currency icon][CurrencyIconState] in the start slot, a
|
||||
* [title][TokenSummaryHeaderUM.title] over a [subtitle][TokenSummaryHeaderUM.subtitle] in the center slot, and a
|
||||
* trailing close (`✕`) button in the end slot. Title and subtitle are single-line and ellipsized on overflow.
|
||||
*
|
||||
* Hosted inside a modal bottom sheet, so [WindowInsets] is zeroed (no status-bar reservation) and the background blur
|
||||
* is disabled.
|
||||
*
|
||||
* @param header content of the navigation bar — the currency icon, title and subtitle to display.
|
||||
* @param onCloseClick invoked when the trailing close button is tapped.
|
||||
* @param modifier [Modifier] applied to the root navigation bar.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TokenSummaryTopNavigation(
|
||||
header: TokenSummaryHeaderUM,
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClick: () -> Unit,
|
||||
) {
|
||||
TangemTopNavigation(
|
||||
modifier = modifier,
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
startButton = {
|
||||
TangemIcon(
|
||||
tangemIconUM = header.tangemIconUM,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
},
|
||||
endButton = {
|
||||
TangemButton.Close(
|
||||
onClick = onCloseClick,
|
||||
)
|
||||
},
|
||||
contentColumn = {
|
||||
Text(
|
||||
text = header.title.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (header.subtitle != null) {
|
||||
Text(
|
||||
text = header.subtitle.resolveReference(),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
|
||||
@Preview(name = "Light", showBackground = true, widthDp = 360)
|
||||
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun TokenSummaryTopNavigationPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.primary),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TokenSummaryTopNavigation(
|
||||
header = previewHeader(
|
||||
title = stringReference("Ethereum"),
|
||||
subtitle = stringReference("ETH"),
|
||||
),
|
||||
onCloseClick = {},
|
||||
)
|
||||
TokenSummaryTopNavigation(
|
||||
header = previewHeader(
|
||||
title = stringReference("Ethereum"),
|
||||
subtitle = null,
|
||||
),
|
||||
onCloseClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewHeader(title: TextReference, subtitle: TextReference?) = TokenSummaryHeaderUM(
|
||||
tangemIconUM = TangemIconUM.Currency(
|
||||
CurrencyIconState.CustomTokenIcon(
|
||||
tint = TangemColorPalette.Black,
|
||||
background = TangemColorPalette.Meadow,
|
||||
topBadgeIconResId = R.drawable.img_polygon_22,
|
||||
isGrayscale = false,
|
||||
),
|
||||
),
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.ui.preivew
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeShape
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal fun previewTokenSummary(periodPickerUm: PeriodPickerUM, tokenSentiment: TokenSentimentUM) = TokenSummaryUm(
|
||||
header = TokenSummaryHeaderUM(
|
||||
tangemIconUM = TangemIconUM.Currency(
|
||||
CurrencyIconState.CustomTokenIcon(
|
||||
tint = TangemColorPalette.Black,
|
||||
background = TangemColorPalette.Meadow,
|
||||
topBadgeIconResId = R.drawable.img_polygon_22,
|
||||
isGrayscale = false,
|
||||
),
|
||||
),
|
||||
title = stringReference("Ethereum"),
|
||||
subtitle = stringReference("ETH"),
|
||||
),
|
||||
tokenSentiment = tokenSentiment,
|
||||
periodPicker = periodPickerUm,
|
||||
aiInsight = AiInsightUM.Displayed(
|
||||
"Your portfolio leans on a single asset – BTC is 42% of holdings. Stablecoins add 23% " +
|
||||
"buffer. Consider trimming concentration for a smoother ride",
|
||||
),
|
||||
onPeriodClick = {},
|
||||
onCloseClick = {},
|
||||
onSwapClick = {},
|
||||
onInfoClick = {},
|
||||
)
|
||||
|
||||
internal val previewContentSentiment = TokenSentimentUM.Content(
|
||||
sentiment = stringReference("Negative outlook"),
|
||||
lastUpdate = stringReference("Updated Jan 20 2026, 9:24 PM"),
|
||||
totalScore = -4,
|
||||
indicators = persistentListOf(
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Neutral"),
|
||||
color = TangemBadgeColor.Blue,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.GalaxyScore,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
color = TangemBadgeColor.Green,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.Sentiment,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.RSI,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.MACD,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.MA_CROSS,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package com.tangem.features.foryou.impl.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||
import com.tangem.core.ui.ds2.messagebanner.TangemMessageBanner
|
||||
import com.tangem.core.ui.extensions.conditional
|
||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.entity.ForYouUM
|
||||
import com.tangem.features.foryou.impl.model.ForYouNotification
|
||||
import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData
|
||||
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
internal fun ForYouContent(
|
||||
forYouUM: ForYouUM,
|
||||
bottomSheetState: State<BottomSheetState>,
|
||||
promoBannersBlockComponent: PromoBannersBlockComponent,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LaunchedEffect(bottomSheetState, promoBannersBlockComponent) {
|
||||
snapshotFlow { bottomSheetState.value == BottomSheetState.EXPANDED }
|
||||
.distinctUntilChanged()
|
||||
.collect(promoBannersBlockComponent::setVisibleOnScreen)
|
||||
}
|
||||
val background = LocalMainBottomSheetColor.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = contentPadding.calculateTopPadding())
|
||||
.drawBehind { drawRect(background.value) },
|
||||
) {
|
||||
promoBannersBlockComponent.ContentWithPadding(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
walletId = null,
|
||||
horizontalItemPadding = 16.dp,
|
||||
)
|
||||
|
||||
forYouUM.notifications.fastForEachIndexed { index, notification ->
|
||||
key(notification.state) {
|
||||
TangemMessageBanner(
|
||||
state = notification.state,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.conditional(index == 0) {
|
||||
padding(top = 12.dp)
|
||||
}
|
||||
.conditional(index == forYouUM.notifications.lastIndex) {
|
||||
padding(bottom = 48.dp)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ForYouPortfolioReview(
|
||||
portfolioReviewUM = forYouUM.portfolioReviewUM,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
SpacerH(48.dp)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun ForYouContent_Preview(@PreviewParameter(ForYouContentPreviewProvider::class) params: ForYouUM) {
|
||||
TangemThemePreviewRedesign {
|
||||
ForYouContent(
|
||||
forYouUM = params,
|
||||
bottomSheetState = remember { mutableStateOf(BottomSheetState.EXPANDED) },
|
||||
promoBannersBlockComponent = object : PromoBannersBlockComponent {
|
||||
@Composable
|
||||
override fun ContentWithPadding(horizontalItemPadding: Dp, walletId: String?, modifier: Modifier) {
|
||||
}
|
||||
|
||||
override fun setVisibleOnScreen(isVisible: Boolean) {}
|
||||
},
|
||||
contentPadding = PaddingValues.Zero,
|
||||
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ForYouContentPreviewProvider : PreviewParameterProvider<ForYouUM> {
|
||||
override val values: Sequence<ForYouUM>
|
||||
get() = sequenceOf(
|
||||
ForYouUM(
|
||||
notifications = persistentListOf(ForYouNotification.UsedOutdatedData),
|
||||
portfolioReviewUM = ForYouPortfolioReviewPreviewData.reviewContent,
|
||||
),
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
package com.tangem.features.foryou.impl.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
|
||||
import com.tangem.core.ui.ds2.badge.TangemBadge
|
||||
import com.tangem.core.ui.ds2.shimmers.TangemShimmer
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_down_16
|
||||
import com.tangem.features.foryou.impl.R
|
||||
import com.tangem.features.foryou.impl.components.MarketChart
|
||||
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
||||
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
|
||||
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
||||
import com.tangem.features.foryou.impl.ui.components.ForYouPortfolioTokenList
|
||||
import com.tangem.features.foryou.impl.ui.preview.ForYouPortfolioReviewPreviewData
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
@Composable
|
||||
internal fun ForYouPortfolioReview(portfolioReviewUM: PortfolioReviewUM, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.for_you_portfolio_review_title),
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
TangemBadge(
|
||||
text = stringReference("All accounts"), // TODO For You
|
||||
variant = TangemBadge.Variant.Solid,
|
||||
size = TangemBadge.Size.X9,
|
||||
iconEnd = TangemIconUM.Icon(Icons.ic_chevron_down_16),
|
||||
)
|
||||
}
|
||||
SpacerH(16.dp)
|
||||
|
||||
MarketChart(
|
||||
marketChart = portfolioReviewUM.marketChartUM,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
SpacerH(8.dp)
|
||||
|
||||
when (portfolioReviewUM) {
|
||||
is PortfolioReviewUM.Content -> {
|
||||
TangemSegmentedPicker(
|
||||
tangemSegmentedPickerUM = portfolioReviewUM.periodPickerUM,
|
||||
onClick = portfolioReviewUM.onPeriodClick,
|
||||
)
|
||||
}
|
||||
is PortfolioReviewUM.Loading -> TangemShimmer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
radius = 100.dp,
|
||||
)
|
||||
}
|
||||
|
||||
ForYouPortfolioTokenList(tokenList = portfolioReviewUM.tokenList)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Composable
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
private fun ForYouPortfolioReview_Review(
|
||||
@PreviewParameter(ForYouPortfolioReviewPreviewProvider::class) params: PortfolioReviewUM,
|
||||
) {
|
||||
TangemThemePreviewRedesign {
|
||||
ForYouPortfolioReview(
|
||||
portfolioReviewUM = params,
|
||||
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ForYouPortfolioReviewPreviewProvider : PreviewParameterProvider<PortfolioReviewUM> {
|
||||
override val values: Sequence<PortfolioReviewUM>
|
||||
get() = sequenceOf(
|
||||
ForYouPortfolioReviewPreviewData.reviewContent,
|
||||
PortfolioReviewUM.Loading(
|
||||
marketChartUM = MarketChartUM.NoData,
|
||||
tokenList = buildList {
|
||||
repeat(4) { index ->
|
||||
add(
|
||||
ForYouTokenListItemUM(
|
||||
tokenRowUM = TangemTokenRowUM.Loading(
|
||||
id = index.toString(),
|
||||
),
|
||||
tokenList = persistentListOf(),
|
||||
isExpanded = false,
|
||||
isExpandable = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.toPersistentList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.tangem.features.foryou.impl.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.ds.button.SecondaryTangemButton
|
||||
import com.tangem.core.ui.ds.button.TangemButtonSize
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.foryou.impl.R
|
||||
import com.tangem.features.foryou.impl.components.CanvasGradientDivider
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
|
||||
@Suppress("ModifierHeightWithText")
|
||||
@Composable
|
||||
internal fun AiInsightContent(aiInsightUM: AiInsightUM, modifier: Modifier = Modifier) {
|
||||
AnimatedContent(
|
||||
targetState = aiInsightUM,
|
||||
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
|
||||
) { currentState ->
|
||||
when (currentState) {
|
||||
is AiInsightUM.AskAiInsight -> {
|
||||
SecondaryTangemButton(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
onClick = currentState.askAiInsightClick,
|
||||
size = TangemButtonSize.X9,
|
||||
text = resourceReference(R.string.market_chart_ask_for_ai_summary_button),
|
||||
)
|
||||
}
|
||||
is AiInsightUM.Displayed -> {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(IntrinsicSize.Min),
|
||||
) {
|
||||
CanvasGradientDivider(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp),
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
brush = Brush.horizontalGradient(
|
||||
listOf(
|
||||
TangemTheme.colors3.icon.accent.violet,
|
||||
TangemTheme.colors3.icon.accent.blue,
|
||||
),
|
||||
),
|
||||
alpha = 1f,
|
||||
),
|
||||
) { append(stringResourceSafe(R.string.market_chart_ai_total)) }
|
||||
append(" ")
|
||||
append(currentState.text)
|
||||
},
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
AiInsightUM.Hide -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
package com.tangem.features.foryou.impl.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateIntAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.lerp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import com.tangem.common.ui.tokens.SlideInItemVisibility
|
||||
import com.tangem.core.ui.components.SpacerWMax
|
||||
import com.tangem.core.ui.components.TextShimmer
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.components.account.toBoxSize
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
|
||||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRow
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.generated.icons.Icons
|
||||
import com.tangem.core.ui.res.generated.icons.ic_chevron_collapse_20
|
||||
import com.tangem.core.ui.utils.ProvideSharedTransitionScope
|
||||
import com.tangem.core.ui.utils.lazyListItemPosition
|
||||
import com.tangem.core.ui.utils.sharedBoundsSafely
|
||||
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
|
||||
import com.tangem.utils.StringsSigns
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
internal fun ForYouPortfolioTokenList(tokenList: ImmutableList<ForYouTokenListItemUM>, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
val outerLastIndex = tokenList.lastIndex
|
||||
tokenList.fastForEachIndexed { index, listItem ->
|
||||
key(listItem.tokenRowUM.id) {
|
||||
PortfolioTokenItem(listItem = listItem, index = index, outerLastIndex = outerLastIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PortfolioTokenItem(listItem: ForYouTokenListItemUM, index: Int, outerLastIndex: Int) {
|
||||
PortfolioAssetItem(listItem = listItem, index = index, outerLastIndex = outerLastIndex)
|
||||
|
||||
val lastIndex = listItem.tokenList.lastIndex.inc()
|
||||
listItem.tokenList.fastForEachIndexed { tokenIndex, item ->
|
||||
SlideInItemVisibility(
|
||||
currentIndex = tokenIndex + 1,
|
||||
lastIndex = lastIndex,
|
||||
modifier = Modifier
|
||||
.roundedShapeItemDecoration(
|
||||
radius = 24.dp,
|
||||
currentIndex = tokenIndex + 1,
|
||||
addDefaultPadding = false,
|
||||
lastIndex = lastIndex,
|
||||
backgroundColor = TangemTheme.colors3.bg.secondary,
|
||||
),
|
||||
visible = listItem.isExpanded,
|
||||
) {
|
||||
val itemModifier = Modifier
|
||||
.semantics { lazyListItemPosition = tokenIndex + 1 }
|
||||
|
||||
var position by remember { mutableStateOf(Offset.Zero) }
|
||||
TangemTokenRow(
|
||||
tokenRowUM = item,
|
||||
isBalanceHidden = false, // TODO For You
|
||||
modifier = itemModifier
|
||||
.onGloballyPositioned {
|
||||
position = it.positionInWindow()
|
||||
}
|
||||
.conditionalCompose(item.onItemClick != null) {
|
||||
clickable(onClick = requireNotNull(item.onItemClick))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun PortfolioAssetItem(listItem: ForYouTokenListItemUM, index: Int, outerLastIndex: Int) {
|
||||
val itemBackgroundColor = TangemTheme.colors3.bg.secondary
|
||||
|
||||
ProvideSharedTransitionScope(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.semantics { lazyListItemPosition = index }
|
||||
.roundedShapeItemDecoration(
|
||||
currentIndex = 0,
|
||||
radius = 24.dp,
|
||||
addDefaultPadding = false,
|
||||
lastIndex = portfolioAssetExpandAnimation(listItem = listItem, outerLastIndex = outerLastIndex).value,
|
||||
backgroundColor = itemBackgroundColor,
|
||||
),
|
||||
) {
|
||||
val iconSharedContentState = rememberSharedContentState(key = "icon_${listItem.tokenRowUM.id}")
|
||||
val titleSharedContentState = rememberSharedContentState(key = "title_${listItem.tokenRowUM.id}")
|
||||
val boundsTransform = BoundsTransform { _, _ -> tween(250) }
|
||||
|
||||
AnimatedContent(
|
||||
targetState = listItem.isExpanded,
|
||||
transitionSpec = { portfolioAssetExpandFadeAnimation() },
|
||||
) { isExpandedWrapped ->
|
||||
val composables = remember(isExpandedWrapped) {
|
||||
SharedTokenRowComposables(
|
||||
icon = { modifier ->
|
||||
PortfolioSharedAssetIcon(
|
||||
listItem = listItem,
|
||||
isExpandedWrapped = isExpandedWrapped,
|
||||
itemBackgroundColor = itemBackgroundColor,
|
||||
modifier = modifier.sharedBoundsSafely(
|
||||
sharedContentState = iconSharedContentState,
|
||||
animatedVisibilityScope = this,
|
||||
boundsTransform = boundsTransform,
|
||||
),
|
||||
)
|
||||
},
|
||||
title = { modifier ->
|
||||
PortfolioSharedAssetTitle(
|
||||
listItem = listItem,
|
||||
isExpandedWrapped = isExpandedWrapped,
|
||||
modifier = modifier.sharedBoundsSafely(
|
||||
sharedContentState = titleSharedContentState,
|
||||
animatedVisibilityScope = this,
|
||||
boundsTransform = boundsTransform,
|
||||
resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpandedWrapped) {
|
||||
ForYouPortfolioListHeader(
|
||||
tokenRowUM = listItem.tokenRowUM,
|
||||
headComponent = composables.icon,
|
||||
titleComponent = composables.title,
|
||||
)
|
||||
} else {
|
||||
TangemTokenRow(
|
||||
tokenRowUM = listItem.tokenRowUM,
|
||||
headComponent = composables.icon,
|
||||
titleComponent = composables.title,
|
||||
isBalanceHidden = false, // todo For You
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PortfolioSharedAssetIcon(
|
||||
listItem: ForYouTokenListItemUM,
|
||||
isExpandedWrapped: Boolean,
|
||||
itemBackgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val headIcon = listItem.tokenRowUM.headIconUM
|
||||
|
||||
if (headIcon is TangemIconUM.Currency) {
|
||||
val size = if (isExpandedWrapped) {
|
||||
AccountIconSize.RedesignExtraSmall
|
||||
} else {
|
||||
AccountIconSize.RedesignedDefault
|
||||
}
|
||||
val currencyIconState = when (val currencyIconState = headIcon.currencyIconState) {
|
||||
is CurrencyIconState.CryptoPortfolio.Icon -> currencyIconState.copy(size = size)
|
||||
is CurrencyIconState.CryptoPortfolio.Letter -> currencyIconState.copy(size = size)
|
||||
else -> currencyIconState
|
||||
}
|
||||
TangemCurrencyIcon(
|
||||
state = currencyIconState,
|
||||
shouldDisplayNetwork = false,
|
||||
modifier = modifier
|
||||
.size(size.toBoxSize())
|
||||
// TODO For You replace with DC components
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
if (!isExpandedWrapped) {
|
||||
val offset = 34.dp.toPx()
|
||||
drawBadge(
|
||||
color = Color.Red,
|
||||
containerColor = itemBackgroundColor,
|
||||
offset = Offset(
|
||||
x = offset,
|
||||
y = offset,
|
||||
),
|
||||
size = 3.dp,
|
||||
padding = 1.dp,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PortfolioSharedAssetTitle(
|
||||
listItem: ForYouTokenListItemUM,
|
||||
isExpandedWrapped: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val targetAnimationFraction = if (isExpandedWrapped) 0f else 1f
|
||||
|
||||
val animationFraction = animateFloatAsState(
|
||||
targetValue = targetAnimationFraction,
|
||||
animationSpec = tween(durationMillis = 350),
|
||||
)
|
||||
|
||||
val startStyle = TangemTheme.typography3.subheading.medium
|
||||
val stopStyle = TangemTheme.typography3.body.medium
|
||||
|
||||
val textStyle by remember(animationFraction.value) {
|
||||
derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) }
|
||||
}
|
||||
|
||||
val resizedTitle = when (val titleUM = listItem.tokenRowUM.titleUM) {
|
||||
is TangemTokenRowUM.TitleUM.Content -> titleUM.copy(
|
||||
text = styledStringReference(
|
||||
titleUM.text.resolveReference(),
|
||||
{ textStyle.toSpanStyle() },
|
||||
),
|
||||
)
|
||||
else -> titleUM
|
||||
}
|
||||
|
||||
TokenRowTitle(
|
||||
titleUM = if (isExpandedWrapped) {
|
||||
(resizedTitle as? TangemTokenRowUM.TitleUM.Content)?.copy(badge = null) ?: resizedTitle
|
||||
} else {
|
||||
resizedTitle
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ForYouPortfolioListHeader(
|
||||
tokenRowUM: TangemTokenRowUM,
|
||||
headComponent: @Composable (Modifier) -> Unit,
|
||||
titleComponent: @Composable (Modifier) -> Unit,
|
||||
) {
|
||||
TangemRow(
|
||||
modifier = Modifier.background(TangemTheme.colors3.bg.secondary),
|
||||
divider = true,
|
||||
onClick = tokenRowUM.onItemClick,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
startSlot = {
|
||||
headComponent(Modifier)
|
||||
},
|
||||
titleSlot = {
|
||||
titleComponent(Modifier)
|
||||
|
||||
val topEndUM = tokenRowUM.topEndContentUM
|
||||
val bottomEndUM = tokenRowUM.bottomEndContentUM
|
||||
when {
|
||||
topEndUM is TangemTokenRowUM.EndContentUM.Content &&
|
||||
bottomEndUM is TangemTokenRowUM.EndContentUM.Content -> {
|
||||
Text(
|
||||
text = annotatedReference {
|
||||
appendColored(StringsSigns.DOT, TangemTheme.colors3.icon.tertiary)
|
||||
appendSpace()
|
||||
append(topEndUM.text.resolveReference())
|
||||
appendSpace()
|
||||
appendColored(StringsSigns.DOT, TangemTheme.colors3.icon.tertiary)
|
||||
appendSpace()
|
||||
appendColored(bottomEndUM.text.resolveReference(), TangemTheme.colors3.text.secondary)
|
||||
}.resolveAnnotatedReference(),
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
)
|
||||
}
|
||||
topEndUM is TangemTokenRowUM.EndContentUM.Loading ||
|
||||
bottomEndUM is TangemTokenRowUM.EndContentUM.Loading -> {
|
||||
TextShimmer(style = TangemTheme.typography3.subheading.medium)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
SpacerWMax()
|
||||
Icon(
|
||||
imageVector = Icons.ic_chevron_collapse_20,
|
||||
tint = TangemTheme.colors3.icon.primary,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBadge(
|
||||
containerColor: Color,
|
||||
color: Color = TangemColorPalette.Azure,
|
||||
offset: Offset,
|
||||
size: Dp = 5.dp,
|
||||
padding: Dp = 2.dp,
|
||||
) {
|
||||
drawCircle(
|
||||
color = containerColor,
|
||||
center = offset,
|
||||
radius = size.toPx(),
|
||||
)
|
||||
drawCircle(
|
||||
color = color,
|
||||
center = offset,
|
||||
radius = (size - padding).toPx(),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
private fun portfolioAssetExpandAnimation(listItem: ForYouTokenListItemUM, outerLastIndex: Int): State<Int> {
|
||||
// Snap immediately on expand; on collapse, hold the current value until all
|
||||
// child items finish their shrink animation, then snap to fully-rounded shape.
|
||||
return animateIntAsState(
|
||||
targetValue = if (listItem.isExpanded) outerLastIndex else 0,
|
||||
animationSpec = if (listItem.isExpanded) {
|
||||
snap()
|
||||
} else {
|
||||
snap(delayMillis = minOf(50 * maxOf(listItem.tokenList.lastIndex, 0), 250) + 150)
|
||||
},
|
||||
label = "lastIndex",
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun portfolioAssetExpandFadeAnimation(): ContentTransform {
|
||||
return fadeIn(animationSpec = tween(350, delayMillis = 90))
|
||||
.togetherWith(fadeOut(animationSpec = tween(350)))
|
||||
}
|
||||
|
||||
@Stable
|
||||
internal class SharedTokenRowComposables(
|
||||
val title: @Composable (Modifier) -> Unit,
|
||||
val icon: @Composable (Modifier) -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
package com.tangem.features.foryou.impl.ui.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Visual state of the [GradientScaleBar].
|
||||
*/
|
||||
internal sealed interface GradientScaleBarState {
|
||||
|
||||
/**
|
||||
* Loaded state — renders the horizontal error → info → success gradient track with a circular
|
||||
* indicator snapped to [value].
|
||||
*
|
||||
* @param value current value to point at; coerced into [range].
|
||||
* @param range inclusive range of selectable values; its size defines the number of positions
|
||||
* (default [DEFAULT_RANGE] = `-5..5`, i.e. 11 positions).
|
||||
*/
|
||||
data class Content(
|
||||
@param:IntRange(from = -5, to = 5) val value: Int,
|
||||
val range: kotlin.ranges.IntRange = DEFAULT_RANGE,
|
||||
) : GradientScaleBarState
|
||||
|
||||
/** Loading state — renders an animated shimmer placeholder sized to the track. */
|
||||
data object Loading : GradientScaleBarState
|
||||
|
||||
/** No-data state — renders a static disabled track. */
|
||||
data object NoData : GradientScaleBarState
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal gradient scale bar with a round indicator snapped to a value on the scale.
|
||||
*
|
||||
* Renders one of three variants depending on [state]:
|
||||
* - [GradientScaleBarState.Content] — the error → info → success gradient track with the circular
|
||||
* indicator snapped to one of the evenly-spaced positions defined by its range (e.g. the default
|
||||
* `-5..5` yields 11 positions). The indicator never overflows the track: its center travels from
|
||||
* the left edge (`range.first`) to the right edge (`range.last`).
|
||||
* - [GradientScaleBarState.Loading] — an animated shimmer placeholder.
|
||||
* - [GradientScaleBarState.NoData] — a static disabled track.
|
||||
*
|
||||
* All variants share the same track geometry ([TRACK_HEIGHT], [TRACK_CORNER]) and occupy the same
|
||||
* vertical space ([INDICATOR_SIZE]), so switching between states does not shift the layout.
|
||||
*
|
||||
* @param state visual state to render.
|
||||
* @param modifier the [Modifier] to be applied to the component. Width is taken from the incoming
|
||||
* constraints (defaults to intrinsic content otherwise) — pass `Modifier.fillMaxWidth()` to stretch.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GradientScaleBar(state: GradientScaleBarState, modifier: Modifier = Modifier) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.padding(vertical = 5.dp)
|
||||
.height(INDICATOR_SIZE),
|
||||
) {
|
||||
when (state) {
|
||||
is GradientScaleBarState.Content -> ContentBar(state = state)
|
||||
GradientScaleBarState.Loading -> RectangleShimmer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(TRACK_HEIGHT)
|
||||
.align(Alignment.Center),
|
||||
radius = TRACK_CORNER,
|
||||
)
|
||||
GradientScaleBarState.NoData -> Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(TRACK_HEIGHT)
|
||||
.align(Alignment.Center)
|
||||
.background(TangemTheme.colors3.bg.disabled, RoundedCornerShape(TRACK_CORNER)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxWithConstraintsScope.ContentBar(state: GradientScaleBarState.Content) {
|
||||
val trackBrush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
TangemTheme.colors3.bg.status.error,
|
||||
TangemTheme.colors3.bg.status.info,
|
||||
TangemTheme.colors3.bg.status.success,
|
||||
),
|
||||
)
|
||||
val indicatorColor = TangemTheme.colors3.icon.primary
|
||||
|
||||
// Track — centered vertically, thinner than the indicator.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(TRACK_HEIGHT)
|
||||
.offset(y = (INDICATOR_SIZE - TRACK_HEIGHT) / 2)
|
||||
.background(trackBrush, RoundedCornerShape(TRACK_CORNER))
|
||||
.clip(RoundedCornerShape(TRACK_CORNER)),
|
||||
)
|
||||
|
||||
// Indicator — snapped to one of the positions defined by the range.
|
||||
val range = state.range
|
||||
val steps = range.last - range.first + 1
|
||||
val fraction = if (steps <= 1) {
|
||||
0f
|
||||
} else {
|
||||
(state.value.coerceIn(range) - range.first).toFloat() / (steps - 1)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset {
|
||||
val travel = maxWidth.toPx() - INDICATOR_SIZE.toPx()
|
||||
IntOffset(x = (fraction * travel).roundToInt(), y = 0)
|
||||
}
|
||||
.size(INDICATOR_SIZE)
|
||||
.dropShadow(
|
||||
shape = CircleShape,
|
||||
shadow = Shadow(
|
||||
radius = 4.dp, // TODO
|
||||
spread = 0.dp,
|
||||
color = Color.Black.copy(alpha = 0.25f),
|
||||
),
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(indicatorColor),
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_RANGE = -5..5
|
||||
private val INDICATOR_SIZE = 10.dp
|
||||
private val TRACK_HEIGHT = 6.dp
|
||||
private val TRACK_CORNER = 10.dp
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun GradientScaleBar_Preview() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors3.bg.primary)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.Content(value = -5),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.Loading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.NoData,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.tangem.features.foryou.impl.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
|
||||
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
|
||||
|
||||
/**
|
||||
* Horizontal wallet pill-tab strip for the For You screen.
|
||||
*
|
||||
* Faithful replica of the `WalletTabItem` / `walletListItem` reference in
|
||||
* `features/common-features/impl/.../choosetoken/ui/ChooseTokenScreen.kt` (which is `private`),
|
||||
* adapted to a standalone composable (For You renders inside a plain scrollable Column, not a LazyListScope).
|
||||
*/
|
||||
@Composable
|
||||
internal fun WalletTabsBlock(walletList: WalletListUM, modifier: Modifier = Modifier) {
|
||||
if (walletList.items.isEmpty()) return
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
items(walletList.items) { um ->
|
||||
WalletTabItem(um)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
|
||||
val isSelected = state.isSelected
|
||||
val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary
|
||||
val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1
|
||||
val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary
|
||||
val countBackground = if (isSelected) {
|
||||
TangemTheme.colors.button.secondary.copy(alpha = 0.2f)
|
||||
} else {
|
||||
TangemTheme.colors.button.primary.copy(alpha = 0.1f)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = state.onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = state.text.resolveReference(),
|
||||
color = buttonTextColor,
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
)
|
||||
|
||||
val count = state.count
|
||||
if (count != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(countBackground, shape = CircleShape)
|
||||
.defaultMinSize(minWidth = 20.dp)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = count.resolveReference(),
|
||||
color = countTextColor,
|
||||
style = TangemTheme.typography.caption1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package com.tangem.features.foryou.impl.ui.preview
|
||||
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.features.foryou.impl.components.state.DonutChartUM
|
||||
import com.tangem.features.foryou.impl.components.state.DonutSegmentColor
|
||||
import com.tangem.features.foryou.impl.components.state.DonutSegmentUM
|
||||
import com.tangem.features.foryou.impl.components.state.MarketChartUM
|
||||
import com.tangem.features.foryou.impl.entity.ForYouTokenListItemUM
|
||||
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
||||
import com.tangem.utils.StringsSigns.DOT
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal object ForYouPortfolioReviewPreviewData {
|
||||
|
||||
val reviewContent = PortfolioReviewUM.Content(
|
||||
periodPickerUM = TangemSegmentedPickerUM(
|
||||
items = persistentListOf(
|
||||
TangemSegmentUM(id = "0", title = stringReference("Day")),
|
||||
TangemSegmentUM(id = "1", title = stringReference("Week")),
|
||||
TangemSegmentUM(id = "2", title = stringReference("Month")),
|
||||
),
|
||||
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
|
||||
isFixed = true,
|
||||
isAltSurface = true,
|
||||
),
|
||||
onPeriodClick = {},
|
||||
marketChartUM = MarketChartUM.Loaded(
|
||||
donutChart = DonutChartUM.Loaded(
|
||||
totalAmount = "10000$",
|
||||
// Colours are assigned in segment order (rank), matching the transformer's palette-by-index.
|
||||
donutSegmentList = persistentListOf(
|
||||
DonutSegmentUM(
|
||||
color = DonutSegmentColor.Brand,
|
||||
weight = BigDecimal("0.55"),
|
||||
title = stringReference("Ethereum"),
|
||||
fiatValue = stringReference("\$5,720.22"),
|
||||
),
|
||||
DonutSegmentUM(
|
||||
color = DonutSegmentColor.Green,
|
||||
weight = BigDecimal("0.45"),
|
||||
title = stringReference("Solana"),
|
||||
fiatValue = stringReference("\$728.30"),
|
||||
),
|
||||
),
|
||||
),
|
||||
topHoldingPercent = stringReference("Top holding 42%"),
|
||||
),
|
||||
tokenList = persistentListOf(
|
||||
ForYouTokenListItemUM(
|
||||
tokenRowUM = TangemTokenRowUM.Content(
|
||||
id = "network_0",
|
||||
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(
|
||||
text = stringReference("USDC"),
|
||||
badge = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
size = TangemBadgeSize.X4,
|
||||
type = TangemBadgeType.Tinted,
|
||||
color = TangemBadgeColor.Green,
|
||||
),
|
||||
),
|
||||
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = stringReference("2 networks"),
|
||||
),
|
||||
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference("\$5,479"),
|
||||
),
|
||||
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference("54,8%"),
|
||||
),
|
||||
onItemClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
),
|
||||
tokenList = persistentListOf(
|
||||
TangemTokenRowUM.Content(
|
||||
id = "network_0_token_0",
|
||||
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(
|
||||
text = stringReference("USDC"),
|
||||
badge = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
size = TangemBadgeSize.X4,
|
||||
type = TangemBadgeType.Tinted,
|
||||
color = TangemBadgeColor.Green,
|
||||
),
|
||||
),
|
||||
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = stringReference("Solana $DOT 3,479 USDC"),
|
||||
),
|
||||
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference("\$3,479"),
|
||||
),
|
||||
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference("34,7%"),
|
||||
),
|
||||
onItemClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
),
|
||||
TangemTokenRowUM.Content(
|
||||
id = "network_0_token_1",
|
||||
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(
|
||||
text = stringReference("USDC"),
|
||||
badge = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
size = TangemBadgeSize.X4,
|
||||
type = TangemBadgeType.Tinted,
|
||||
color = TangemBadgeColor.Green,
|
||||
),
|
||||
),
|
||||
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = stringReference("Ethereum $DOT 2,000 USDC"),
|
||||
),
|
||||
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference("\$2,000"),
|
||||
),
|
||||
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference("20,1%"),
|
||||
),
|
||||
onItemClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
),
|
||||
),
|
||||
isExpanded = true,
|
||||
isExpandable = true,
|
||||
),
|
||||
ForYouTokenListItemUM(
|
||||
tokenRowUM = TangemTokenRowUM.Content(
|
||||
id = "network_1",
|
||||
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
|
||||
titleUM = TangemTokenRowUM.TitleUM.Content(
|
||||
text = stringReference("Bitcoin"),
|
||||
badge = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
size = TangemBadgeSize.X4,
|
||||
type = TangemBadgeType.Tinted,
|
||||
color = TangemBadgeColor.Green,
|
||||
),
|
||||
),
|
||||
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
|
||||
text = stringReference("Main network"),
|
||||
),
|
||||
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference("\$849"),
|
||||
),
|
||||
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
|
||||
text = stringReference("8,49%"),
|
||||
),
|
||||
onItemClick = {},
|
||||
onItemLongClick = { _, _ -> },
|
||||
),
|
||||
tokenList = persistentListOf(),
|
||||
isExpanded = false,
|
||||
isExpandable = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
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
|
||||
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.StatusSource
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
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
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class ForYouModelTest {
|
||||
|
||||
private val userWalletsListRepository: UserWalletsListRepository = mockk()
|
||||
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk()
|
||||
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
||||
|
||||
private var model: ForYouModel? = null
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
// Default: a real, non-empty emission so the model's `getOrElse { Default }` mapping path is
|
||||
// actually exercised in every test, not bypassed by an empty flow.
|
||||
every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right())
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
model?.onDestroy()
|
||||
model = null
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class InitialState {
|
||||
|
||||
@Test
|
||||
fun `GIVEN model created WHEN not yet advanced THEN uiState is Loading with skeleton rows`() = runTest {
|
||||
// Arrange
|
||||
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null)
|
||||
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(linkedMapOf())
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
|
||||
// Assert — before advancing, the model exposes skeleton placeholder rows
|
||||
val loading = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Loading
|
||||
assertThat(loading.tokenList).hasSize(4)
|
||||
assertThat(loading.tokenList.all { it.tokenRowUM is TangemTokenRowUM.Loading }).isTrue()
|
||||
assertThat(loading.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ContentState {
|
||||
|
||||
@Test
|
||||
fun `GIVEN selected wallet and statuses emitted WHEN advanced THEN uiState becomes Content`() = runTest {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||
stubSelectedWallet(
|
||||
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val content = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
||||
assertThat(content.tokenList.map { it.tokenRowUM.id }).containsExactly("btc")
|
||||
assertThat(content.marketChartUM).isInstanceOf(MarketChartUM.Loaded::class.java)
|
||||
assertThat(model.uiState.value.notifications).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN total balance from outdated source WHEN advanced THEN outdated-data notification is shown`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||
stubSelectedWallet(
|
||||
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
)
|
||||
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.notifications).containsExactly(ForYouNotification.UsedOutdatedData)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ExpandClick {
|
||||
|
||||
@Test
|
||||
fun `GIVEN asset row clicked WHEN clicked again THEN isExpanded toggles back to false`() = runTest {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||
stubSelectedWallet(
|
||||
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
)
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
val initialContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
||||
assertThat(initialContent.tokenList.single().isExpanded).isFalse()
|
||||
|
||||
// Act — click once to expand
|
||||
initialContent.assetRow().onItemClick?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val expandedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
||||
assertThat(expandedContent.tokenList.single().isExpanded).isTrue()
|
||||
|
||||
// Act — click again to collapse
|
||||
expandedContent.assetRow().onItemClick?.invoke()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val collapsedContent = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
||||
assertThat(collapsedContent.tokenList.single().isExpanded).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class PeriodClick {
|
||||
|
||||
@Test
|
||||
fun `GIVEN Content state WHEN period clicked THEN initialSelectedItem updates without resetting rest`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "btc", symbol = "BTC")
|
||||
stubSelectedWallet(
|
||||
currencies = listOf(createStatus(currency, loadedValue(BigDecimal("100")))),
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
)
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
val contentBefore = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
||||
val weekItem = contentBefore.periodPickerUM.items[1]
|
||||
|
||||
// Act
|
||||
contentBefore.onPeriodClick(weekItem)
|
||||
|
||||
// Assert
|
||||
val contentAfter = model.uiState.value.portfolioReviewUM as PortfolioReviewUM.Content
|
||||
assertThat(contentAfter.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
|
||||
assertThat(contentAfter.tokenList).isEqualTo(contentBefore.tokenList)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PortfolioReviewUM.Content.assetRow(): TangemTokenRowUM.Content =
|
||||
tokenList.single().tokenRowUM as TangemTokenRowUM.Content
|
||||
|
||||
/** Wires the repository + supplier so the model derives Content from a single selected wallet. */
|
||||
private fun stubSelectedWallet(
|
||||
currencies: List<CryptoCurrencyStatus>,
|
||||
totalFiatBalance: BigDecimal,
|
||||
source: StatusSource = StatusSource.ACTUAL,
|
||||
) {
|
||||
val wallet = MockUserWalletFactory.create().copy(walletId = UserWalletId("01"))
|
||||
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet)
|
||||
every { multiAccountStatusListSupplier.invokeAsMap() } returns flowOf(
|
||||
linkedMapOf(
|
||||
wallet.walletId to createAccountStatusList(currencies, totalFiatBalance, source),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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(),
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
).also { model = it }
|
||||
}
|
||||
|
||||
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAccountStatusList(
|
||||
currencies: List<CryptoCurrencyStatus>,
|
||||
totalFiatBalance: BigDecimal,
|
||||
source: StatusSource = StatusSource.ACTUAL,
|
||||
): AccountStatusList = mockk {
|
||||
every { flattenCurrencies() } returns currencies
|
||||
every { this@mockk.totalFiatBalance } returns TotalFiatBalance.Loaded(
|
||||
amount = totalFiatBalance,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = value,
|
||||
)
|
||||
|
||||
private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
|
||||
every { amount } returns BigDecimal.ONE
|
||||
every { this@mockk.fiatAmount } returns fiatAmount
|
||||
every { isError } returns false
|
||||
every { sources } returns CryptoCurrencyStatus.Sources()
|
||||
}
|
||||
|
||||
private fun createCoin(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin {
|
||||
val network: Network = mockk {
|
||||
every { name } returns "Network"
|
||||
every { isTestnet } returns false
|
||||
every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) }
|
||||
}
|
||||
val currencyId: CryptoCurrency.ID = mockk {
|
||||
every { value } returns "coin-$rawCurrencyId"
|
||||
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
|
||||
}
|
||||
return mockk<CryptoCurrency.Coin> {
|
||||
every { this@mockk.id } returns currencyId
|
||||
every { this@mockk.symbol } returns symbol
|
||||
every { this@mockk.name } returns symbol
|
||||
every { this@mockk.network } returns network
|
||||
every { this@mockk.decimals } returns 8
|
||||
every { isCustom } returns false
|
||||
every { iconUrl } returns null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
package com.tangem.features.foryou.impl.model.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class ForYouPortfolioFormattersTest {
|
||||
|
||||
@Nested
|
||||
inner class ForYouGroupKey {
|
||||
|
||||
@Test
|
||||
fun `GIVEN standard currency with raw id WHEN forYouGroupKey THEN returns rawCurrencyId value`() {
|
||||
// Arrange
|
||||
val id: CryptoCurrency.ID = mockk {
|
||||
every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin")
|
||||
every { value } returns "coin-id-value"
|
||||
}
|
||||
val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id }
|
||||
val status = createStatus(currency)
|
||||
|
||||
// Act
|
||||
val result = status.forYouGroupKey()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo("bitcoin")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN custom token with no raw id WHEN forYouGroupKey THEN falls back to currency id value`() {
|
||||
// Arrange
|
||||
val id: CryptoCurrency.ID = mockk {
|
||||
every { rawCurrencyId } returns null
|
||||
every { value } returns "custom-currency-id"
|
||||
}
|
||||
val currency: CryptoCurrency = mockk { every { this@mockk.id } returns id }
|
||||
val status = createStatus(currency)
|
||||
|
||||
// Act
|
||||
val result = status.forYouGroupKey()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo("custom-currency-id")
|
||||
}
|
||||
|
||||
private fun createStatus(currency: CryptoCurrency): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = CryptoCurrencyStatus.Loading,
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class ToForYouPercent {
|
||||
|
||||
@Test
|
||||
fun `GIVEN null amount WHEN toForYouPercent THEN returns null`() {
|
||||
// Arrange
|
||||
val amount: BigDecimal? = null
|
||||
|
||||
// Act
|
||||
val result = amount.toForYouPercent(BigDecimal("100"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero total WHEN toForYouPercent THEN returns null`() {
|
||||
// Arrange
|
||||
val amount = BigDecimal("10")
|
||||
|
||||
// Act
|
||||
val result = amount.toForYouPercent(BigDecimal.ZERO)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero amount WHEN toForYouPercent THEN returns null`() {
|
||||
// Arrange
|
||||
val amount = BigDecimal.ZERO
|
||||
|
||||
// Act
|
||||
val result = amount.toForYouPercent(BigDecimal("100"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-zero amount and total WHEN toForYouPercent THEN returns the share as a ratio`() {
|
||||
// Arrange — 50.00 / 200 = 0.25 (ratio, scaled to the amount's scale)
|
||||
val amount = BigDecimal("50.00")
|
||||
|
||||
// Act
|
||||
val result = amount.toForYouPercent(BigDecimal("200"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(BigDecimal("0.25"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a share requiring rounding WHEN toForYouPercent THEN applies HALF_UP rounding`() {
|
||||
// Arrange — 1.0000 / 3 = 0.3333... rounds HALF_UP to the amount's scale (4)
|
||||
val amount = BigDecimal("1.0000")
|
||||
|
||||
// Act
|
||||
val result = amount.toForYouPercent(BigDecimal("3"))
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(BigDecimal("0.3333"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
package com.tangem.features.foryou.impl.model.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.extensions.pluralReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.features.foryou.impl.R
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class ForYouTokenListConverterTest {
|
||||
|
||||
private val appCurrency: AppCurrency = AppCurrency.Default
|
||||
|
||||
@Nested
|
||||
inner class Convert {
|
||||
|
||||
@Test
|
||||
fun `GIVEN single-network coin WHEN convert THEN subtitle is common main network`() {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("100"))
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(status))
|
||||
|
||||
// Assert
|
||||
val row = result.single().tokenRowUM as TangemTokenRowUM.Content
|
||||
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||
assertThat(subtitle.text).isEqualTo(resourceReference(R.string.common_main_network))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN single-network token WHEN convert THEN subtitle is the network standard type name`() {
|
||||
// Arrange
|
||||
val currency = createToken(
|
||||
rawCurrencyId = "usdc",
|
||||
symbol = "USDC",
|
||||
networkId = "ethereum",
|
||||
standardTypeName = "ERC20",
|
||||
)
|
||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("100"))
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(status))
|
||||
|
||||
// Assert
|
||||
val row = result.single().tokenRowUM as TangemTokenRowUM.Content
|
||||
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||
assertThat(subtitle.text).isEqualTo(stringReference("ERC20"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN asset spans multiple networks WHEN convert THEN subtitle shows network count`() {
|
||||
// Arrange — same asset (shared rawCurrencyId) on two different networks
|
||||
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
|
||||
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
|
||||
val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("200")))
|
||||
val converter = createConverter(
|
||||
totalFiatBalance = BigDecimal("300"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(statusEth, statusSol))
|
||||
|
||||
// Assert
|
||||
val item = result.single()
|
||||
val row = item.tokenRowUM as TangemTokenRowUM.Content
|
||||
val subtitle = row.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_networks_count, count = 2))
|
||||
assertThat(item.tokenList).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multi-network asset WHEN convert THEN child rows ordered by descending fiat balance`() {
|
||||
// Arrange
|
||||
val onEth = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "ethereum")
|
||||
val onSol = createToken(rawCurrencyId = "usdc", symbol = "USDC", networkId = "solana")
|
||||
val statusEth = createStatus(onEth, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val statusSol = createStatus(onSol, loadedValue(BigDecimal("2"), BigDecimal("500")))
|
||||
val converter = createConverter(
|
||||
totalFiatBalance = BigDecimal("600"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(statusEth, statusSol))
|
||||
|
||||
// Assert — Solana holding (500) ranks above Ethereum holding (100)
|
||||
val childIds = result.single().tokenList.map { it.id }
|
||||
assertThat(childIds).containsExactly("token-usdc-solana", "token-usdc-ethereum").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN all statuses of an asset are Loading WHEN convert THEN asset row is Loading`() {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||
val status = createStatus(currency, CryptoCurrencyStatus.Loading)
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal.ZERO)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(status))
|
||||
|
||||
// Assert
|
||||
assertThat(result.single().tokenRowUM).isInstanceOf(TangemTokenRowUM.Loading::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no other assets WHEN convert THEN no Other row is appended`() {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val converter = createConverter(
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
otherAssets = emptyList(),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(status))
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a single other asset WHEN convert THEN Other row subtitle is singular`() {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val converter = createConverter(
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
otherAssets = listOf(otherAsset(BigDecimal("50"))),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(status))
|
||||
|
||||
// Assert
|
||||
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
|
||||
assertThat(otherRow.id).isEqualTo("for_you_other_assets")
|
||||
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN more than one other asset WHEN convert THEN Other row subtitle is plural`() {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val converter = createConverter(
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
otherAssets = listOf(
|
||||
otherAsset(BigDecimal("30")),
|
||||
otherAsset(BigDecimal("15")),
|
||||
otherAsset(BigDecimal("5")),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(status))
|
||||
|
||||
// Assert
|
||||
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
|
||||
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
|
||||
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 3))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN asset id in expandedAssetIds WHEN convert THEN item isExpanded is true`() {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val converter = createConverter(
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
expandedAssetIds = setOf("bitcoin"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(status))
|
||||
|
||||
// Assert
|
||||
assertThat(result.single().isExpanded).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN asset id not in expandedAssetIds WHEN convert THEN item isExpanded is false`() {
|
||||
// Arrange
|
||||
val currency = createCoin(rawCurrencyId = "bitcoin", symbol = "BTC", networkId = "bitcoin")
|
||||
val status = createStatus(currency, loadedValue(BigDecimal("1"), BigDecimal("100")))
|
||||
val converter = createConverter(
|
||||
totalFiatBalance = BigDecimal("100"),
|
||||
expandedAssetIds = emptySet(),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(listOf(status))
|
||||
|
||||
// Assert
|
||||
assertThat(result.single().isExpanded).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createConverter(
|
||||
totalFiatBalance: BigDecimal,
|
||||
expandedAssetIds: Set<String> = emptySet(),
|
||||
otherAssets: List<Pair<List<CryptoCurrencyStatus>, BigDecimal>> = emptyList(),
|
||||
): ForYouTokenListConverter = ForYouTokenListConverter(
|
||||
appCurrency = appCurrency,
|
||||
totalFiatBalance = totalFiatBalance,
|
||||
expandedAssetIds = expandedAssetIds,
|
||||
expandClick = {},
|
||||
otherAssets = otherAssets,
|
||||
onTokenClick = {},
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an "other" asset entry — only its summed [balance] and the number of entries drive the
|
||||
* collapsed "Other" row, so the currency list is left empty.
|
||||
*/
|
||||
private fun otherAsset(balance: BigDecimal): Pair<List<CryptoCurrencyStatus>, BigDecimal> =
|
||||
emptyList<CryptoCurrencyStatus>() to balance
|
||||
|
||||
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = value,
|
||||
)
|
||||
|
||||
private fun loadedValue(amount: BigDecimal, fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
|
||||
every { this@mockk.amount } returns amount
|
||||
every { this@mockk.fiatAmount } returns fiatAmount
|
||||
every { isError } returns false
|
||||
every { sources } returns CryptoCurrencyStatus.Sources()
|
||||
}
|
||||
|
||||
private fun createCoin(rawCurrencyId: String, symbol: String, networkId: String): CryptoCurrency.Coin {
|
||||
val network = createNetwork(networkId = networkId, standardTypeName = "MAIN")
|
||||
val currencyId = createCurrencyId(idValue = "coin-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
|
||||
return mockk<CryptoCurrency.Coin> {
|
||||
every { this@mockk.id } returns currencyId
|
||||
every { this@mockk.symbol } returns symbol
|
||||
every { this@mockk.network } returns network
|
||||
every { this@mockk.decimals } returns 8
|
||||
every { isCustom } returns false
|
||||
every { iconUrl } returns null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createToken(
|
||||
rawCurrencyId: String,
|
||||
symbol: String,
|
||||
networkId: String,
|
||||
standardTypeName: String = "ERC20",
|
||||
): CryptoCurrency.Token {
|
||||
val network = createNetwork(networkId = networkId, standardTypeName = standardTypeName)
|
||||
val currencyId = createCurrencyId(idValue = "token-$rawCurrencyId-$networkId", rawCurrencyId = rawCurrencyId)
|
||||
return mockk<CryptoCurrency.Token> {
|
||||
every { this@mockk.id } returns currencyId
|
||||
every { this@mockk.symbol } returns symbol
|
||||
every { this@mockk.network } returns network
|
||||
every { this@mockk.decimals } returns 6
|
||||
every { isCustom } returns false
|
||||
every { iconUrl } returns null
|
||||
every { contractAddress } returns "0xCONTRACT"
|
||||
}
|
||||
}
|
||||
|
||||
private fun createCurrencyId(idValue: String, rawCurrencyId: String): CryptoCurrency.ID = mockk {
|
||||
every { value } returns idValue
|
||||
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
|
||||
}
|
||||
|
||||
private fun createNetwork(networkId: String, standardTypeName: String): Network {
|
||||
val standardType: Network.StandardType = mockk {
|
||||
every { name } returns standardTypeName
|
||||
}
|
||||
return mockk {
|
||||
every { id } returns mockk {
|
||||
every { rawId } returns Network.RawID(networkId)
|
||||
}
|
||||
every { name } returns networkId
|
||||
every { isTestnet } returns false
|
||||
every { this@mockk.standardType } returns standardType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
package com.tangem.features.foryou.impl.model.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.percent
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class ForYouTokenRowConverterTest {
|
||||
|
||||
private val appCurrency: AppCurrency = AppCurrency.Default
|
||||
|
||||
@Nested
|
||||
inner class ConvertNetworkGroup {
|
||||
|
||||
@Test
|
||||
fun `GIVEN all statuses Loading WHEN convertNetworkGroup THEN row is Loading with representative id`() {
|
||||
// Arrange
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
|
||||
val statuses = listOf(createStatus(currency, CryptoCurrencyStatus.Loading))
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(TangemTokenRowUM.Loading(id = "coin-eth"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN single loaded status WHEN convertNetworkGroup THEN row is Content with its amounts`() {
|
||||
// Arrange
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
|
||||
val statuses = listOf(
|
||||
createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))),
|
||||
)
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||
|
||||
// Assert
|
||||
assertThat(result.id).isEqualTo("coin-eth")
|
||||
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
assertThat(topEnd.text).isEqualTo(BigDecimal("400").expectedFiatText())
|
||||
assertThat(bottomEnd.text).isEqualTo(BigDecimal("400").expectedPercentText(BigDecimal("1000")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN several statuses of the same asset on one network WHEN convertNetworkGroup THEN amounts are summed`() {
|
||||
// Arrange — same asset held in two accounts on the same network aggregates into one row
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
|
||||
val statuses = listOf(
|
||||
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("200"))),
|
||||
createStatus(currency, loadedValue(amount = BigDecimal("2"), fiatAmount = BigDecimal("400"))),
|
||||
)
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||
|
||||
// Assert
|
||||
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
assertThat(topEnd.text).isEqualTo(BigDecimal("600").expectedFiatText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN mixed Loading and Loaded statuses WHEN convertNetworkGroup THEN row is Content`() {
|
||||
// Arrange — not *all* statuses are Loading, so it should not collapse to a Loading row
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH")
|
||||
val statuses = listOf(
|
||||
createStatus(currency, CryptoCurrencyStatus.Loading),
|
||||
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))),
|
||||
)
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(TangemTokenRowUM.Content::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status from cache WHEN convertNetworkGroup THEN content flickers`() {
|
||||
// Arrange
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
|
||||
val statuses = listOf(
|
||||
createStatus(
|
||||
currency,
|
||||
loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"), source = StatusSource.CACHE),
|
||||
),
|
||||
)
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||
|
||||
// Assert
|
||||
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
assertThat(topEnd.isFlickering).isTrue()
|
||||
assertThat(bottomEnd.isFlickering).isTrue()
|
||||
assertThat(topEnd.startIcons).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status only-cache WHEN convertNetworkGroup THEN error-sync start icon shown`() {
|
||||
// Arrange
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
|
||||
val statuses = listOf(
|
||||
createStatus(
|
||||
currency,
|
||||
loadedValue(
|
||||
amount = BigDecimal("1"),
|
||||
fiatAmount = BigDecimal("100"),
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
),
|
||||
),
|
||||
)
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||
|
||||
// Assert
|
||||
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
assertThat(topEnd.isFlickering).isFalse()
|
||||
assertThat(topEnd.startIcons).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missed derivation status WHEN convertNetworkGroup THEN no-address treatment`() {
|
||||
// Arrange
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
|
||||
val statuses = listOf(createStatus(currency, missedDerivationValue()))
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||
|
||||
// Assert — top-end is a dash, bottom-end carries the attention "no address" icon
|
||||
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
assertThat(topEnd.endIcons).isEmpty()
|
||||
assertThat(bottomEnd.endIcons).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unreachable status WHEN convertNetworkGroup THEN dash on top and attention icon on bottom`() {
|
||||
// Arrange
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
|
||||
val statuses = listOf(createStatus(currency, unreachableValue()))
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||
|
||||
// Assert — top-end is a bare dash, the attention "unreachable" icon lives on the bottom end
|
||||
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
assertThat(topEnd.endIcons).isEmpty()
|
||||
assertThat(bottomEnd.endIcons).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN mixed Loaded and Unreachable WHEN convertNetworkGroup THEN collapses to unreachable`() {
|
||||
// Arrange — one account resolved, another unreachable: the row must surface the error state
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
|
||||
val statuses = listOf(
|
||||
createStatus(currency, loadedValue(amount = BigDecimal("1"), fiatAmount = BigDecimal("100"))),
|
||||
createStatus(currency, unreachableValue()),
|
||||
)
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||
|
||||
// Assert — the unreachable treatment (attention icon on the bottom end) wins over the loaded amount
|
||||
val bottomEnd = result.bottomEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
assertThat(bottomEnd.endIcons).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN mixed MissedDerivation and Unreachable WHEN convertNetworkGroup THEN missed-derivation wins`() {
|
||||
// Arrange — missed derivation is the most severe terminal state and dominates
|
||||
val currency = createCurrency(id = "coin-eth", symbol = "ETH", networkName = "Ethereum")
|
||||
val statuses = listOf(
|
||||
createStatus(currency, unreachableValue()),
|
||||
createStatus(currency, missedDerivationValue()),
|
||||
)
|
||||
val converter = createConverter(totalFiatBalance = BigDecimal("1000"))
|
||||
|
||||
// Act
|
||||
val result = converter.convertNetworkGroup(statuses) as TangemTokenRowUM.Content
|
||||
|
||||
// Assert — top-end is a dash (no-address treatment), not an unreachable label
|
||||
val topEnd = result.topEndContentUM as TangemTokenRowUM.EndContentUM.Content
|
||||
assertThat(topEnd.endIcons).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createConverter(totalFiatBalance: BigDecimal) = ForYouTokenRowConverter(
|
||||
appCurrency = appCurrency,
|
||||
totalFiatBalance = totalFiatBalance,
|
||||
onTokenClick = {},
|
||||
)
|
||||
|
||||
/** Mirrors the production fiat rendering used by [ForYouTokenRowConverter] for a resolved row. */
|
||||
private fun BigDecimal.expectedFiatText(): TextReference = stringReference(
|
||||
format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) },
|
||||
)
|
||||
|
||||
/** Mirrors the production percent-share rendering used by [ForYouTokenRowConverter] for a resolved row. */
|
||||
private fun BigDecimal.expectedPercentText(total: BigDecimal): TextReference = stringReference(
|
||||
toForYouPercent(total).format { percent() },
|
||||
)
|
||||
|
||||
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = value,
|
||||
)
|
||||
|
||||
private fun loadedValue(
|
||||
amount: BigDecimal,
|
||||
fiatAmount: BigDecimal,
|
||||
source: StatusSource = StatusSource.ACTUAL,
|
||||
): CryptoCurrencyStatus.Loaded = mockk {
|
||||
every { this@mockk.amount } returns amount
|
||||
every { this@mockk.fiatAmount } returns fiatAmount
|
||||
every { isError } returns false
|
||||
every { sources } returns CryptoCurrencyStatus.Sources(
|
||||
networkSource = source,
|
||||
quoteSource = source,
|
||||
stakingBalanceSource = source,
|
||||
)
|
||||
}
|
||||
|
||||
private fun missedDerivationValue(): CryptoCurrencyStatus.MissedDerivation = mockk {
|
||||
every { amount } returns null
|
||||
every { fiatAmount } returns null
|
||||
every { isError } returns true
|
||||
}
|
||||
|
||||
private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = mockk {
|
||||
every { amount } returns null
|
||||
every { fiatAmount } returns null
|
||||
every { isError } returns true
|
||||
}
|
||||
|
||||
private fun createCurrency(
|
||||
id: String,
|
||||
symbol: String,
|
||||
networkName: String = "Network",
|
||||
): CryptoCurrency {
|
||||
val network: Network = mockk {
|
||||
every { name } returns networkName
|
||||
every { isTestnet } returns false
|
||||
every { this@mockk.id } returns mockk { every { rawId } returns Network.RawID(id) }
|
||||
}
|
||||
val currencyId: CryptoCurrency.ID = mockk {
|
||||
every { value } returns id
|
||||
every { rawCurrencyId } returns null
|
||||
}
|
||||
return mockk<CryptoCurrency.Coin> {
|
||||
every { this@mockk.id } returns currencyId
|
||||
every { this@mockk.symbol } returns symbol
|
||||
every { this@mockk.network } returns network
|
||||
every { this@mockk.decimals } returns 8
|
||||
every { isCustom } returns false
|
||||
every { iconUrl } returns null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
package com.tangem.features.foryou.impl.model.transformer
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
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.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
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
|
||||
import com.tangem.features.foryou.impl.entity.PortfolioReviewUM
|
||||
import com.tangem.features.foryou.impl.model.ForYouNotification
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class SetPortfolioReviewTransformerTest {
|
||||
|
||||
private val appCurrency: AppCurrency = AppCurrency.Default
|
||||
|
||||
@Nested
|
||||
inner class TokenList {
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency with resolved zero fiat balance WHEN transform THEN it is dropped from the list`() {
|
||||
// Arrange
|
||||
val zeroBalance = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
|
||||
val nonZeroBalance = createCurrency(rawCurrencyId = "eth", symbol = "ETH")
|
||||
val currencies = listOf(
|
||||
createStatus(zeroBalance, loadedValue(BigDecimal.ZERO)),
|
||||
createStatus(nonZeroBalance, loadedValue(BigDecimal("100"))),
|
||||
)
|
||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert — only the ETH asset survives; the zero-fiat BTC is dropped
|
||||
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-content status with null fiat WHEN transform THEN it is kept not dropped`() {
|
||||
// Arrange — a non-content status (Unreachable) carries a null fiatAmount, not a resolved zero;
|
||||
// it must still be shown so the user sees the token they hold, with the appropriate treatment.
|
||||
val unreachable = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
|
||||
val loaded = createCurrency(rawCurrencyId = "eth", symbol = "ETH")
|
||||
val currencies = listOf(
|
||||
createStatus(unreachable, unreachableValue()),
|
||||
createStatus(loaded, loadedValue(BigDecimal("100"))),
|
||||
)
|
||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert — both assets kept, ranked by summed fiat (eth 100 > btc 0)
|
||||
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("eth", "btc").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same asset across networks WHEN transform THEN aggregated into one asset ranked by summed fiat`() {
|
||||
// Arrange — the same asset (shared rawCurrencyId "usdc") aggregates into one asset
|
||||
val onEth = createCurrency(rawCurrencyId = "usdc", symbol = "USDC")
|
||||
val onSol = createCurrency(rawCurrencyId = "usdc", symbol = "USDC")
|
||||
val other = createCurrency(rawCurrencyId = "btc", symbol = "BTC")
|
||||
val currencies = listOf(
|
||||
createStatus(onEth, loadedValue(BigDecimal("50"))),
|
||||
createStatus(onSol, loadedValue(BigDecimal("60"))),
|
||||
createStatus(other, loadedValue(BigDecimal("10"))),
|
||||
)
|
||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("120"))))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert — 2 ranked assets: usdc (110 total) ahead of btc (10)
|
||||
assertThat(result.tokenList.map { it.tokenRowUM.id }).containsExactly("usdc", "btc").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN more than TOP_HOLDINGS_COUNT assets WHEN transform THEN excess assets collapse into Other`() {
|
||||
// Arrange — 5 distinct assets, top 4 kept individually, 5th collapsed into "Other"
|
||||
val currencies = (1..5).map { index ->
|
||||
createStatus(
|
||||
createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"),
|
||||
loadedValue(BigDecimal(100 - index)),
|
||||
)
|
||||
}
|
||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("470"))))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert — 4 top asset rows + 1 "Other" row
|
||||
assertThat(result.tokenList).hasSize(5)
|
||||
assertThat(result.tokenList.last().tokenRowUM.id).isEqualTo("for_you_other_assets")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN exactly TOP_HOLDINGS_COUNT assets WHEN transform THEN no Other row is appended`() {
|
||||
// Arrange
|
||||
val currencies = (1..4).map { index ->
|
||||
createStatus(
|
||||
createCurrency(rawCurrencyId = "asset-$index", symbol = "A$index"),
|
||||
loadedValue(BigDecimal(100 - index)),
|
||||
)
|
||||
}
|
||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("394"))))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert
|
||||
assertThat(result.tokenList).hasSize(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null account status list WHEN transform THEN token list is empty`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(accountStatusList = null)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert
|
||||
assertThat(result.tokenList).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class MarketChart {
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded total balance WHEN transform THEN market chart is Loaded with one segment per top asset`() {
|
||||
// Arrange
|
||||
val currencies = listOf(
|
||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("70"))),
|
||||
createStatus(createCurrency(rawCurrencyId = "eth", symbol = "ETH"), loadedValue(BigDecimal("30"))),
|
||||
)
|
||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("100"))))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert
|
||||
val marketChart = result.marketChartUM as MarketChartUM.Loaded
|
||||
assertThat(marketChart.assetCount).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-loaded total balance WHEN transform THEN market chart is NoData`() {
|
||||
// Arrange
|
||||
val currencies = listOf(
|
||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("100"))),
|
||||
)
|
||||
val transformer = createTransformer(accountStatusList(currencies, TotalFiatBalance.Loading))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert
|
||||
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null account status list WHEN transform THEN market chart is NoData`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(accountStatusList = null)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert
|
||||
assertThat(result.marketChartUM).isEqualTo(MarketChartUM.NoData)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class PeriodPicker {
|
||||
|
||||
@Test
|
||||
fun `GIVEN prev state is Loading WHEN transform THEN period picker is freshly created with Day selected`() {
|
||||
// Arrange
|
||||
val currencies = listOf(
|
||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
|
||||
)
|
||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10"))))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert
|
||||
assertThat(result.periodPickerUM.items.map { it.title }).containsExactly(
|
||||
stringReference("Day"),
|
||||
stringReference("Week"),
|
||||
stringReference("Month"),
|
||||
).inOrder()
|
||||
assertThat(result.periodPickerUM.initialSelectedItem?.title).isEqualTo(stringReference("Day"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN prev state is Content WHEN transform THEN period picker selection is preserved`() {
|
||||
// Arrange
|
||||
val currencies = listOf(
|
||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
|
||||
)
|
||||
val transformer = createTransformer(accountStatusList(currencies, loaded(BigDecimal("10"))))
|
||||
val prevContent = transformer.transform(loadingState()).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
val weekItem = prevContent.periodPickerUM.items[1]
|
||||
val prevState = loadingState().copy(
|
||||
portfolioReviewUM = prevContent.copy(
|
||||
periodPickerUM = prevContent.periodPickerUM.copy(initialSelectedItem = weekItem),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState).portfolioReviewUM as PortfolioReviewUM.Content
|
||||
|
||||
// Assert
|
||||
assertThat(result.periodPickerUM.initialSelectedItem).isEqualTo(weekItem)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Notifications {
|
||||
|
||||
@Test
|
||||
fun `GIVEN total balance from outdated source WHEN transform THEN outdated-data notification is emitted`() {
|
||||
// Arrange
|
||||
val currencies = listOf(
|
||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
|
||||
)
|
||||
val transformer = createTransformer(
|
||||
accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ONLY_CACHE)),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).containsExactly(ForYouNotification.UsedOutdatedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN total balance from actual source WHEN transform THEN no notification is emitted`() {
|
||||
// Arrange
|
||||
val currencies = listOf(
|
||||
createStatus(createCurrency(rawCurrencyId = "btc", symbol = "BTC"), loadedValue(BigDecimal("10"))),
|
||||
)
|
||||
val transformer = createTransformer(
|
||||
accountStatusList(currencies, loaded(BigDecimal("10"), source = StatusSource.ACTUAL)),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null account status list WHEN transform THEN no notification is emitted`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(accountStatusList = null)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(loadingState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTransformer(
|
||||
accountStatusList: AccountStatusList?,
|
||||
expandedAssetIds: Set<String> = emptySet(),
|
||||
) = SetPortfolioReviewTransformer(
|
||||
accountStatusList = accountStatusList,
|
||||
appCurrency = appCurrency,
|
||||
expandedAssetIds = expandedAssetIds,
|
||||
expandClick = {},
|
||||
onPeriodClick = {},
|
||||
onTokenClick = {},
|
||||
)
|
||||
|
||||
private fun accountStatusList(
|
||||
currencies: List<CryptoCurrencyStatus>,
|
||||
totalFiatBalance: TotalFiatBalance,
|
||||
): AccountStatusList = mockk {
|
||||
every { flattenCurrencies() } returns currencies
|
||||
every { this@mockk.totalFiatBalance } returns totalFiatBalance
|
||||
}
|
||||
|
||||
private fun loaded(amount: BigDecimal, source: StatusSource = StatusSource.ACTUAL): TotalFiatBalance.Loaded =
|
||||
TotalFiatBalance.Loaded(amount = amount, source = source)
|
||||
|
||||
private fun loadingState(): ForYouUM = ForYouUM(
|
||||
portfolioReviewUM = PortfolioReviewUM.Loading(
|
||||
tokenList = persistentListOf<ForYouTokenListItemUM>(),
|
||||
marketChartUM = MarketChartUM.NoData,
|
||||
),
|
||||
notifications = persistentListOf(),
|
||||
)
|
||||
|
||||
private fun createStatus(currency: CryptoCurrency, value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = value,
|
||||
)
|
||||
|
||||
private fun loadedValue(fiatAmount: BigDecimal): CryptoCurrencyStatus.Loaded = mockk {
|
||||
every { amount } returns BigDecimal.ONE
|
||||
every { this@mockk.fiatAmount } returns fiatAmount
|
||||
every { isError } returns false
|
||||
every { sources } returns CryptoCurrencyStatus.Sources()
|
||||
}
|
||||
|
||||
/** A non-content status: carries a null fiatAmount (unknown balance), not a resolved zero. */
|
||||
private fun unreachableValue(): CryptoCurrencyStatus.Unreachable = CryptoCurrencyStatus.Unreachable(
|
||||
priceChange = null,
|
||||
fiatRate = null,
|
||||
networkAddress = null,
|
||||
)
|
||||
|
||||
private fun createCurrency(rawCurrencyId: String, symbol: String): CryptoCurrency.Coin {
|
||||
val network: Network = mockk {
|
||||
every { name } returns "Network"
|
||||
every { isTestnet } returns false
|
||||
every { id } returns mockk { every { rawId } returns Network.RawID(rawCurrencyId) }
|
||||
}
|
||||
val currencyId: CryptoCurrency.ID = mockk {
|
||||
every { value } returns "coin-$rawCurrencyId"
|
||||
every { this@mockk.rawCurrencyId } returns CryptoCurrency.RawID(rawCurrencyId)
|
||||
}
|
||||
return mockk<CryptoCurrency.Coin> {
|
||||
every { this@mockk.id } returns currencyId
|
||||
every { this@mockk.symbol } returns symbol
|
||||
every { this@mockk.name } returns symbol
|
||||
every { this@mockk.network } returns network
|
||||
every { this@mockk.decimals } returns 8
|
||||
every { isCustom } returns false
|
||||
every { iconUrl } returns null
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue