Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-13 10:11:38 +02:00
parent 16b069cf7a
commit 76cfb2e462
21 changed files with 1649 additions and 70 deletions

View file

@ -14,4 +14,7 @@ dependencies {
/** Project - Core */
api(projects.core.decompose)
api(projects.core.ui)
/** Project - Domain */
api(projects.domain.models)
}

View file

@ -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>
}

View file

@ -3,6 +3,7 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
@ -31,6 +32,7 @@ dependencies {
/** Project - Common */
api(projects.common.ui)
implementation(projects.common.routing)
/** Project - Domain */
api(projects.domain.account.status)
@ -54,6 +56,7 @@ dependencies {
/** 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)

View file

@ -1,10 +1,6 @@
package com.tangem.features.foryou.impl.components
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
@ -16,23 +12,18 @@ 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.graphics.Brush
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.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
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.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds2.surface.TangemSurface
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.format
@ -41,6 +32,7 @@ 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
@ -69,7 +61,13 @@ internal fun MarketChart(marketChart: MarketChartUM, modifier: Modifier = Modifi
}
Spacer(modifier = Modifier.height(16.dp))
AiInsightContent(marketChart.aiInsight)
if (marketChart.aiInsight is AiInsightUM.Displayed) SpacerH8()
AiInsightContent(
aiInsightUM = marketChart.aiInsight,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
}
}
@ -244,65 +242,6 @@ private fun ColumnScope.CantLoadDataBlock() {
)
}
// IntrinsicSize.Min lets the gradient divider match the AI text height — heightIn wouldn't achieve that.
@Suppress("ModifierHeightWithText")
@Composable
private fun AiInsightContent(aiInsightUM: AiInsightUM) {
AnimatedContent(
targetState = aiInsightUM,
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
) { currentState ->
when (currentState) {
is AiInsightUM.AskAiInsight -> {
SecondaryTangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
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)
.padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
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 -> {}
}
}
}
// region Previews
private enum class MarketChartPreviewScenario { DISPLAYED, ASK_AI, NO_AI, NO_DATA }

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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"),
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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?,
)

View file

@ -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,
)

View file

@ -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))
}
}

View file

@ -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,
),
)

View file

@ -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

View file

@ -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,
)
}
},
)
}

View file

@ -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

View file

@ -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,
),
),
)

View file

@ -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 -> {}
}
}
}

View file

@ -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(),
)
}
}
}