From 76cfb2e4621e4f5e9929fb8c1eaa8eb95cafe9b1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Jul 2026 10:11:38 +0200 Subject: [PATCH] Updated on 2026-08-14 --- features/for-you/api/build.gradle.kts | 3 + .../features/foryou/TokenSummaryComponent.kt | 43 ++ features/for-you/impl/build.gradle.kts | 3 + .../foryou/impl/components/MarketChart.kt | 79 +--- .../DefaultTokenSummaryComponent.kt | 112 +++++ .../di/TokenSummaryComponentModule.kt | 27 ++ .../entity/InfoBottomSheetContent.kt | 13 + .../tokensummary/entity/PeriodPickerUM.kt | 14 + .../tokensummary/entity/TokenIndicatorUM.kt | 29 ++ .../tokensummary/entity/TokenSentimentUM.kt | 33 ++ .../entity/TokenSummaryBottomSheetConfig.kt | 20 + .../entity/TokenSummaryHeaderUM.kt | 10 + .../tokensummary/entity/TokenSummaryUm.kt | 15 + .../tokensummary/model/TokenSummaryModel.kt | 204 ++++++++++ .../transformer/TokenSummaryTransformer.kt | 124 ++++++ .../tokensummary/ui/TokenSummaryContent.kt | 383 ++++++++++++++++++ .../ui/components/InfoBottomSheet.kt | 72 ++++ .../components/TokenSummaryTopNavigation.kt | 132 ++++++ .../ui/preivew/TokenSummaryPreviewData.kt | 138 +++++++ .../impl/ui/components/AiInsightContent.kt | 84 ++++ .../impl/ui/components/GradientScaleBar.kt | 181 +++++++++ 21 files changed, 1649 insertions(+), 70 deletions(-) create mode 100644 features/for-you/api/src/main/kotlin/com/tangem/features/foryou/TokenSummaryComponent.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/DefaultTokenSummaryComponent.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/di/TokenSummaryComponentModule.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/InfoBottomSheetContent.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/PeriodPickerUM.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenIndicatorUM.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSentimentUM.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryBottomSheetConfig.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryHeaderUM.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryUm.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/model/TokenSummaryModel.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/model/transformer/TokenSummaryTransformer.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/TokenSummaryContent.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/components/InfoBottomSheet.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/components/TokenSummaryTopNavigation.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/preivew/TokenSummaryPreviewData.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/AiInsightContent.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/GradientScaleBar.kt diff --git a/features/for-you/api/build.gradle.kts b/features/for-you/api/build.gradle.kts index 21b67c2ca2..00a502c585 100644 --- a/features/for-you/api/build.gradle.kts +++ b/features/for-you/api/build.gradle.kts @@ -14,4 +14,7 @@ dependencies { /** Project - Core */ api(projects.core.decompose) api(projects.core.ui) + + /** Project - Domain */ + api(projects.domain.models) } \ No newline at end of file diff --git a/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/TokenSummaryComponent.kt b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/TokenSummaryComponent.kt new file mode 100644 index 0000000000..22d920deeb --- /dev/null +++ b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/TokenSummaryComponent.kt @@ -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 +} \ No newline at end of file diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts index 47701bc7ee..26cbf40712 100644 --- a/features/for-you/impl/build.gradle.kts +++ b/features/for-you/impl/build.gradle.kts @@ -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) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt index 0da5cb11a4..dd56102885 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/components/MarketChart.kt @@ -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 } diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/DefaultTokenSummaryComponent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/DefaultTokenSummaryComponent.kt new file mode 100644 index 0000000000..0062eab6d2 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/DefaultTokenSummaryComponent.kt @@ -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) { + val uiState by model.uiState.collectAsStateWithLifecycle() + + TokenSummaryTopNavigation( + header = uiState.header, + onCloseClick = uiState.onCloseClick, + ) + } + + @Composable + override fun Content( + bottomSheetState: State, + 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 + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/di/TokenSummaryComponentModule.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/di/TokenSummaryComponentModule.kt new file mode 100644 index 0000000000..00bc73a785 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/di/TokenSummaryComponentModule.kt @@ -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 +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/InfoBottomSheetContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/InfoBottomSheetContent.kt new file mode 100644 index 0000000000..9e0ef787a9 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/InfoBottomSheetContent.kt @@ -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 \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/PeriodPickerUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/PeriodPickerUM.kt new file mode 100644 index 0000000000..73452dbf10 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/PeriodPickerUM.kt @@ -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 +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenIndicatorUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenIndicatorUM.kt new file mode 100644 index 0000000000..a5b91cc290 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenIndicatorUM.kt @@ -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"), +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSentimentUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSentimentUM.kt new file mode 100644 index 0000000000..cc4836e623 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSentimentUM.kt @@ -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 + + data class Content( + val sentiment: TextReference, + @param:IntRange(from = -5, to = 5) + val totalScore: Int, + val lastUpdate: TextReference, + override val indicators: ImmutableList, + ) : TokenSentimentUM() + + data object Empty : TokenSentimentUM() { + override val indicators: ImmutableList = IndicatorType.entries + .map { TokenIndicatorUM.NoData(indicatorType = it) } + .toImmutableList() + } + + data object Loading : TokenSentimentUM() { + override val indicators: ImmutableList = IndicatorType.entries + .map { TokenIndicatorUM.Loading(indicatorType = it) } + .toImmutableList() + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryBottomSheetConfig.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryBottomSheetConfig.kt new file mode 100644 index 0000000000..a5e3deea18 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryBottomSheetConfig.kt @@ -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 +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryHeaderUM.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryHeaderUM.kt new file mode 100644 index 0000000000..1b75d75475 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryHeaderUM.kt @@ -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?, +) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryUm.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryUm.kt new file mode 100644 index 0000000000..9a387bb3bc --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/entity/TokenSummaryUm.kt @@ -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, +) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/model/TokenSummaryModel.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/model/TokenSummaryModel.kt new file mode 100644 index 0000000000..a49c3b90af --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/model/TokenSummaryModel.kt @@ -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() + + 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 = 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 + field = MutableStateFlow(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() + .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)) + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/model/transformer/TokenSummaryTransformer.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/model/transformer/TokenSummaryTransformer.kt new file mode 100644 index 0000000000..a06b66416e --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/model/transformer/TokenSummaryTransformer.kt @@ -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 { + + 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, + ), +) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/TokenSummaryContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/TokenSummaryContent.kt new file mode 100644 index 0000000000..5e91d8c7ba --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/TokenSummaryContent.kt @@ -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, + 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 \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/components/InfoBottomSheet.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/components/InfoBottomSheet.kt new file mode 100644 index 0000000000..2ca41f1f11 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/components/InfoBottomSheet.kt @@ -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( + 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, + ) + } + }, + ) +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/components/TokenSummaryTopNavigation.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/components/TokenSummaryTopNavigation.kt new file mode 100644 index 0000000000..77e75c0950 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/components/TokenSummaryTopNavigation.kt @@ -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 \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/preivew/TokenSummaryPreviewData.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/preivew/TokenSummaryPreviewData.kt new file mode 100644 index 0000000000..f07ff188a6 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/tokensummary/ui/preivew/TokenSummaryPreviewData.kt @@ -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, + ), + ), +) \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/AiInsightContent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/AiInsightContent.kt new file mode 100644 index 0000000000..2b0fb3a663 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/AiInsightContent.kt @@ -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 -> {} + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/GradientScaleBar.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/GradientScaleBar.kt new file mode 100644 index 0000000000..6ec3fba840 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/ui/components/GradientScaleBar.kt @@ -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(), + ) + } + } +} \ No newline at end of file