Updated on 2026-08-14
This commit is contained in:
parent
16b069cf7a
commit
76cfb2e462
21 changed files with 1649 additions and 70 deletions
|
|
@ -14,4 +14,7 @@ dependencies {
|
|||
/** Project - Core */
|
||||
api(projects.core.decompose)
|
||||
api(projects.core.ui)
|
||||
|
||||
/** Project - Domain */
|
||||
api(projects.domain.models)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import com.tangem.features.foryou.TokenSummaryComponent
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.InfoBottomSheetContent
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryBottomSheetConfig
|
||||
import com.tangem.features.foryou.impl.tokensummary.model.TokenSummaryModel
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.TokenSummaryContent
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.components.InfoBottomSheet
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.components.TokenSummaryTopNavigation
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
||||
internal class DefaultTokenSummaryComponent @AssistedInject constructor(
|
||||
@Assisted context: AppComponentContext,
|
||||
@Assisted private val params: TokenSummaryComponent.Params,
|
||||
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
|
||||
) : TokenSummaryComponent, AppComponentContext by context {
|
||||
|
||||
private val model: TokenSummaryModel = getOrCreateModel(params = params)
|
||||
|
||||
private val bottomSheetSlot = childSlot(
|
||||
source = model.bottomSheetNavigation,
|
||||
serializer = TokenSummaryBottomSheetConfig.serializer(),
|
||||
handleBackButton = false,
|
||||
childFactory = { config, componentContext ->
|
||||
when (config) {
|
||||
TokenSummaryBottomSheetConfig.PortfolioSelector -> portfolioSelectorChild(componentContext)
|
||||
is TokenSummaryBottomSheetConfig.Info -> infoChild(config)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent =
|
||||
portfolioSelectorComponentFactory.create(
|
||||
context = childByContext(componentContext),
|
||||
params = PortfolioSelectorComponent.Params(
|
||||
portfolioFetcher = model.portfolioFetcher,
|
||||
controller = model.portfolioSelectorController,
|
||||
bsCallback = model.portfolioSelectorCallback,
|
||||
),
|
||||
)
|
||||
|
||||
private fun infoChild(config: TokenSummaryBottomSheetConfig.Info): ComposableBottomSheetComponent =
|
||||
object : ComposableBottomSheetComponent {
|
||||
override fun dismiss() = model.bottomSheetNavigation.dismiss()
|
||||
|
||||
@Composable
|
||||
override fun BottomSheet() {
|
||||
InfoBottomSheet(
|
||||
infoBottomSheetContent = InfoBottomSheetContent(
|
||||
title = stringReference(config.indicatorType.title),
|
||||
body = stringReference("helps to estimate the token's momentum and market sentiment."),
|
||||
),
|
||||
onDismiss = ::dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Title(bottomSheetState: State<BottomSheetState>) {
|
||||
val uiState by model.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
TokenSummaryTopNavigation(
|
||||
header = uiState.header,
|
||||
onCloseClick = uiState.onCloseClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content(
|
||||
bottomSheetState: State<BottomSheetState>,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val state by model.uiState.collectAsStateWithLifecycle()
|
||||
val bottomSheetSlot by bottomSheetSlot.subscribeAsState()
|
||||
|
||||
TokenSummaryContent(
|
||||
tokenSummary = state,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
bottomSheetSlot.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : TokenSummaryComponent.Factory {
|
||||
override fun create(
|
||||
context: AppComponentContext,
|
||||
params: TokenSummaryComponent.Params,
|
||||
): DefaultTokenSummaryComponent
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.di
|
||||
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.features.foryou.TokenSummaryComponent
|
||||
import com.tangem.features.foryou.impl.tokensummary.DefaultTokenSummaryComponent
|
||||
import com.tangem.features.foryou.impl.tokensummary.model.TokenSummaryModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ClassKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface TokenSummaryComponentModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindTokenSummaryComponent(factory: DefaultTokenSummaryComponent.Factory): TokenSummaryComponent.Factory
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(TokenSummaryModel::class)
|
||||
fun bindTokenSummaryModel(impl: TokenSummaryModel): Model
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
/**
|
||||
* Content of the informational bottom sheet shown when the user taps an indicator's info icon on the token
|
||||
* summary screen. Displays a [title] and an explanatory [body].
|
||||
*/
|
||||
internal data class InfoBottomSheetContent(
|
||||
val title: TextReference,
|
||||
val body: TextReference,
|
||||
) : TangemBottomSheetConfigContent
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
|
||||
@Immutable
|
||||
internal sealed interface PeriodPickerUM {
|
||||
|
||||
data class Content(val picker: TangemSegmentedPickerUM) : PeriodPickerUM
|
||||
|
||||
data object Loading : PeriodPickerUM
|
||||
|
||||
data object Empty : PeriodPickerUM
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
|
||||
@Immutable
|
||||
internal sealed interface TokenIndicatorUM {
|
||||
|
||||
val indicatorType: IndicatorType
|
||||
|
||||
data class Content(
|
||||
val sentimentBadge: TangemBadgeUM,
|
||||
val scoreBadge: TangemBadgeUM,
|
||||
override val indicatorType: IndicatorType,
|
||||
) : TokenIndicatorUM
|
||||
|
||||
data class NoData(override val indicatorType: IndicatorType) : TokenIndicatorUM
|
||||
|
||||
data class Loading(override val indicatorType: IndicatorType) : TokenIndicatorUM
|
||||
}
|
||||
|
||||
// TODO find out right source
|
||||
internal enum class IndicatorType(val title: String) {
|
||||
GalaxyScore("Galaxy score"),
|
||||
Sentiment("Sentiment"),
|
||||
RSI("RSI"),
|
||||
MACD("MACD"),
|
||||
MA_CROSS("MA Cross"),
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import androidx.annotation.IntRange
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Immutable
|
||||
internal sealed class TokenSentimentUM {
|
||||
|
||||
abstract val indicators: ImmutableList<TokenIndicatorUM>
|
||||
|
||||
data class Content(
|
||||
val sentiment: TextReference,
|
||||
@param:IntRange(from = -5, to = 5)
|
||||
val totalScore: Int,
|
||||
val lastUpdate: TextReference,
|
||||
override val indicators: ImmutableList<TokenIndicatorUM>,
|
||||
) : TokenSentimentUM()
|
||||
|
||||
data object Empty : TokenSentimentUM() {
|
||||
override val indicators: ImmutableList<TokenIndicatorUM> = IndicatorType.entries
|
||||
.map { TokenIndicatorUM.NoData(indicatorType = it) }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
data object Loading : TokenSentimentUM() {
|
||||
override val indicators: ImmutableList<TokenIndicatorUM> = IndicatorType.entries
|
||||
.map { TokenIndicatorUM.Loading(indicatorType = it) }
|
||||
.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Navigation config for the single bottom-sheet slot hosted by the token summary component.
|
||||
*
|
||||
* Both sheets are mutually exclusive — the slot holds at most one child at a time.
|
||||
*/
|
||||
@Serializable
|
||||
internal sealed interface TokenSummaryBottomSheetConfig {
|
||||
|
||||
/** Portfolio selector shown before opening swap in multi-account mode. */
|
||||
@Serializable
|
||||
data object PortfolioSelector : TokenSummaryBottomSheetConfig
|
||||
|
||||
/** Informational sheet describing the tapped [indicatorType]. */
|
||||
@Serializable
|
||||
data class Info(val indicatorType: IndicatorType) : TokenSummaryBottomSheetConfig
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
internal data class TokenSummaryHeaderUM(
|
||||
val tangemIconUM: TangemIconUM,
|
||||
val title: TextReference,
|
||||
val subtitle: TextReference?,
|
||||
)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.entity
|
||||
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
|
||||
internal data class TokenSummaryUm(
|
||||
val header: TokenSummaryHeaderUM,
|
||||
val periodPicker: PeriodPickerUM,
|
||||
val aiInsight: AiInsightUM,
|
||||
val tokenSentiment: TokenSentimentUM,
|
||||
val onSwapClick: () -> Unit,
|
||||
val onPeriodClick: (TangemSegmentUM) -> Unit,
|
||||
val onInfoClick: (IndicatorType) -> Unit,
|
||||
val onCloseClick: () -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.account.status.producer.SingleAccountStatusProducer
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusSupplier
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
|
||||
import com.tangem.features.foryou.TokenSummaryComponent
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryBottomSheetConfig
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
|
||||
import com.tangem.features.foryou.impl.tokensummary.model.transformer.TokenSummaryTransformer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
import com.tangem.utils.transformer.update
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.Unit
|
||||
|
||||
@Stable
|
||||
@ModelScoped
|
||||
internal class TokenSummaryModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val appRouter: AppRouter,
|
||||
private val portfolioFetcherFactory: PortfolioFetcher.Factory,
|
||||
private val singleAccountStatusSupplier: SingleAccountStatusSupplier,
|
||||
val portfolioSelectorController: PortfolioSelectorController,
|
||||
) : Model() {
|
||||
|
||||
private val params = paramsContainer.require<TokenSummaryComponent.Params>()
|
||||
|
||||
private val iconConverter = CryptoCurrencyToIconStateConverter()
|
||||
private val swapNavigationJob = JobHolder()
|
||||
|
||||
private val selectedTokenPeriodId = MutableStateFlow(value = params.selectedTokenPeriodId)
|
||||
|
||||
/** Drives the single bottom-sheet slot hosted by the component (portfolio selector / info). */
|
||||
val bottomSheetNavigation: SlotNavigation<TokenSummaryBottomSheetConfig> = SlotNavigation()
|
||||
|
||||
/** Feeds the portfolio selector with the wallet's accounts. */
|
||||
val portfolioFetcher: PortfolioFetcher by lazy {
|
||||
portfolioFetcherFactory.create(
|
||||
mode = PortfolioFetcher.Mode.Wallet(params.userWalletId),
|
||||
scope = modelScope,
|
||||
)
|
||||
}
|
||||
|
||||
val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback {
|
||||
override val onDismiss: () -> Unit = { bottomSheetNavigation.dismiss() }
|
||||
override val onBack: () -> Unit = { bottomSheetNavigation.dismiss() }
|
||||
}
|
||||
|
||||
val uiState: StateFlow<TokenSummaryUm>
|
||||
field = MutableStateFlow<TokenSummaryUm>(buildInitialUiState())
|
||||
|
||||
init {
|
||||
selectedTokenPeriodId
|
||||
.onEach { periodId ->
|
||||
uiState.update(
|
||||
TokenSummaryTransformer(),
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun buildInitialUiState(): TokenSummaryUm {
|
||||
return TokenSummaryUm(
|
||||
header = buildHeader(),
|
||||
periodPicker = PeriodPickerUM.Content(
|
||||
TangemSegmentedPickerUM(
|
||||
items = persistentListOf(
|
||||
TangemSegmentUM(id = "0", title = stringReference("Day")),
|
||||
TangemSegmentUM(id = "1", title = stringReference("Week")),
|
||||
TangemSegmentUM(id = "2", title = stringReference("Month")),
|
||||
),
|
||||
initialSelectedItem = null,
|
||||
isFixed = true,
|
||||
isAltSurface = true,
|
||||
),
|
||||
),
|
||||
tokenSentiment = TokenSentimentUM.Loading,
|
||||
aiInsight = AiInsightUM.Hide,
|
||||
onSwapClick = ::onSwapClicked,
|
||||
onPeriodClick = ::onPeriodClick,
|
||||
onInfoClick = ::onInfoClick,
|
||||
onCloseClick = params.callbacks::onDismiss,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildHeader(): TokenSummaryHeaderUM = when (val token = params.token) {
|
||||
is TokenSummaryComponent.Token.Portfolio -> {
|
||||
val currency = token.cryptoCurrency
|
||||
TokenSummaryHeaderUM(
|
||||
tangemIconUM = TangemIconUM.Currency(iconConverter.convert(currency)),
|
||||
title = stringReference(currency.name.ifBlank { currency.symbol }),
|
||||
subtitle = stringReference(currency.network.name),
|
||||
)
|
||||
}
|
||||
is TokenSummaryComponent.Token.Market -> TokenSummaryHeaderUM(
|
||||
tangemIconUM = TangemIconUM.Url(url = token.tangemIconUrl, fallbackRes = R.drawable.ic_custom_token_44),
|
||||
title = stringReference(token.title),
|
||||
subtitle = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPeriodClick(tangemSegmentUM: TangemSegmentUM) {
|
||||
if (tangemSegmentUM.id == selectedTokenPeriodId.value) return
|
||||
|
||||
uiState.update {
|
||||
it.copy(tokenSentiment = TokenSentimentUM.Loading)
|
||||
}
|
||||
|
||||
selectedTokenPeriodId.value = tangemSegmentUM.id
|
||||
}
|
||||
|
||||
private fun onSwapClicked() {
|
||||
modelScope.launch {
|
||||
val account = if (portfolioSelectorController.isAccountModeSync()) {
|
||||
portfolioSelectorController.selectAccount(null)
|
||||
bottomSheetNavigation.activate(TokenSummaryBottomSheetConfig.PortfolioSelector)
|
||||
|
||||
val (_, selectedAccount) = portfolioSelectorController
|
||||
.selectedAccountWithData(portfolioFetcher)
|
||||
.filterNotNull()
|
||||
.first()
|
||||
|
||||
bottomSheetNavigation.dismiss()
|
||||
selectedAccount
|
||||
} else {
|
||||
singleAccountStatusSupplier(
|
||||
SingleAccountStatusProducer.Params(
|
||||
accountId = AccountId.forMainCryptoPortfolio(params.userWalletId),
|
||||
),
|
||||
)
|
||||
.filterIsInstance<AccountStatus.CryptoPortfolio>()
|
||||
.first()
|
||||
}
|
||||
|
||||
val currency = account.flattenCurrencies()
|
||||
.map(CryptoCurrencyStatus::currency)
|
||||
.firstOrNull(::matchesSummaryToken)
|
||||
|
||||
navigateToSwap(currency)
|
||||
}.saveIn(swapNavigationJob)
|
||||
}
|
||||
|
||||
private fun matchesSummaryToken(currency: CryptoCurrency): Boolean = when (val token = params.token) {
|
||||
is TokenSummaryComponent.Token.Portfolio -> {
|
||||
val summaryCurrency = token.cryptoCurrency
|
||||
currency.id.rawCurrencyId == summaryCurrency.id.rawCurrencyId && currency.network == summaryCurrency.network
|
||||
}
|
||||
is TokenSummaryComponent.Token.Market -> currency.id.rawCurrencyId == token.cryptoCurrencyRawId
|
||||
}
|
||||
|
||||
private fun navigateToSwap(currency: CryptoCurrency?) {
|
||||
appRouter.push(
|
||||
AppRoute.Swap(
|
||||
userWalletId = params.userWalletId,
|
||||
fromCryptoCurrency = currency,
|
||||
screenSource = "screen source", // TODO
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onInfoClick(indicatorType: IndicatorType) {
|
||||
bottomSheetNavigation.activate(TokenSummaryBottomSheetConfig.Info(indicatorType))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.model.transformer
|
||||
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeShape
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class TokenSummaryTransformer : Transformer<TokenSummaryUm> {
|
||||
|
||||
override fun transform(prevState: TokenSummaryUm): TokenSummaryUm {
|
||||
return prevState.copy(
|
||||
tokenSentiment = TokenSentimentUM.Content(
|
||||
sentiment = calculateSentiment(),
|
||||
lastUpdate = stringReference("Updated Jan 20 2026, 9:24 PM"), // TODO For You localization
|
||||
totalScore = -4,
|
||||
indicators = mapIndicators(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateSentiment(): TextReference {
|
||||
val outlook = "Negative"
|
||||
return stringReference("$outlook outlook") // TODO For You localization
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun mapIndicators() = persistentListOf(
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Neutral"),
|
||||
color = TangemBadgeColor.Blue,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.GalaxyScore,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
color = TangemBadgeColor.Green,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.Sentiment,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.RSI,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.MACD,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.MA_CROSS,
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,383 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.ui
|
||||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.PrimaryButton
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.SpacerH32
|
||||
import com.tangem.core.ui.components.SpacerH4
|
||||
import com.tangem.core.ui.components.SpacerH8
|
||||
import com.tangem.core.ui.ds.badge.TangemBadge
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeShape
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
|
||||
import com.tangem.core.ui.ds.tabs.TangemSegmentedPickerUM
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
import com.tangem.core.ui.ds2.row.TangemRowContentLead
|
||||
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.preivew.previewContentSentiment
|
||||
import com.tangem.features.foryou.impl.tokensummary.ui.preivew.previewTokenSummary
|
||||
import com.tangem.features.foryou.impl.ui.components.AiInsightContent
|
||||
import com.tangem.features.foryou.impl.ui.components.GradientScaleBar
|
||||
import com.tangem.features.foryou.impl.ui.components.GradientScaleBarState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
internal fun TokenSummaryContent(
|
||||
tokenSummary: TokenSummaryUm,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var buttonHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = contentPadding.calculateTopPadding()),
|
||||
) {
|
||||
when (val periodPicker = tokenSummary.periodPicker) {
|
||||
is PeriodPickerUM.Content -> TangemSegmentedPicker(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
tangemSegmentedPickerUM = periodPicker.picker,
|
||||
onClick = tokenSummary.onPeriodClick,
|
||||
)
|
||||
PeriodPickerUM.Loading -> RectangleShimmer(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.height(44.dp),
|
||||
radius = 12.dp,
|
||||
)
|
||||
PeriodPickerUM.Empty -> Unit
|
||||
}
|
||||
|
||||
SpacerH32()
|
||||
|
||||
when (val tokenSentiment = tokenSummary.tokenSentiment) {
|
||||
is TokenSentimentUM.Content -> SentimentsContent(
|
||||
tokenSentiment = tokenSentiment,
|
||||
aiInsight = tokenSummary.aiInsight,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
is TokenSentimentUM.Empty -> EmptySentimentContent(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
is TokenSentimentUM.Loading -> LoadingSentimentContent(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
IndicatorsList(
|
||||
indicators = tokenSummary.tokenSentiment.indicators,
|
||||
onInfoClick = tokenSummary.onInfoClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// Reserve space equal to the pinned button's full height
|
||||
Spacer(modifier = Modifier.height(buttonHeight))
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
text = stringResourceSafe(R.string.token_summary_go_to_swap_button),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { buttonHeight = with(density) { it.height.toDp() } }
|
||||
.navigationBarsPadding()
|
||||
.padding(16.dp),
|
||||
onClick = tokenSummary.onSwapClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptySentimentContent(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.token_summary_can_not_load_token),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.NoData,
|
||||
modifier = Modifier.padding(vertical = 40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingSentimentContent(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.token_summary_title),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
SpacerH8()
|
||||
|
||||
RectangleShimmer(
|
||||
modifier = Modifier.size(width = 140.dp, height = 24.dp),
|
||||
)
|
||||
|
||||
SpacerH8()
|
||||
|
||||
RectangleShimmer(
|
||||
modifier = Modifier.size(width = 180.dp, height = 16.dp),
|
||||
)
|
||||
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.Loading,
|
||||
modifier = Modifier.padding(vertical = 40.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SentimentsContent(
|
||||
tokenSentiment: TokenSentimentUM.Content,
|
||||
aiInsight: AiInsightUM,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.token_summary_title),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
SpacerH4()
|
||||
|
||||
Text(
|
||||
text = tokenSentiment.sentiment.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.heading.small,
|
||||
)
|
||||
|
||||
SpacerH4()
|
||||
|
||||
Text(
|
||||
text = tokenSentiment.lastUpdate.resolveReference(),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
|
||||
GradientScaleBar(
|
||||
state = GradientScaleBarState.Content(value = tokenSentiment.totalScore),
|
||||
modifier = Modifier.padding(vertical = 40.dp),
|
||||
)
|
||||
|
||||
if (aiInsight is AiInsightUM.Displayed) SpacerH8()
|
||||
|
||||
AiInsightContent(
|
||||
aiInsightUM = aiInsight,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IndicatorsList(
|
||||
indicators: ImmutableList<TokenIndicatorUM>,
|
||||
onInfoClick: (IndicatorType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
indicators.forEach { indicator ->
|
||||
IndicatorRow(
|
||||
indicator = indicator,
|
||||
onInfoClick = { onInfoClick(indicator.indicatorType) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IndicatorRow(indicator: TokenIndicatorUM, onInfoClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
TangemRow(
|
||||
modifier = modifier,
|
||||
divider = true,
|
||||
includeInnerPaddings = true,
|
||||
contentLead = TangemRowContentLead.Equal,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
titleSlot = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickableSingle(onClick = onInfoClick)
|
||||
.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = indicator.indicatorType.title,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier.size(16.dp),
|
||||
painter = painterResource(id = R.drawable.ic_information_24),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.icon.tertiary,
|
||||
)
|
||||
}
|
||||
},
|
||||
valueSlot = {
|
||||
when (indicator) {
|
||||
is TokenIndicatorUM.Content -> {
|
||||
TangemBadge(badgeUM = indicator.scoreBadge)
|
||||
TangemBadge(badgeUM = indicator.sentimentBadge)
|
||||
}
|
||||
is TokenIndicatorUM.Loading -> {
|
||||
RectangleShimmer(
|
||||
modifier = Modifier.size(width = 48.dp, height = 24.dp),
|
||||
radius = 12.dp,
|
||||
)
|
||||
}
|
||||
is TokenIndicatorUM.NoData -> {
|
||||
TangemBadge(
|
||||
badgeUM = TangemBadgeUM(
|
||||
text = stringReference("None"), // TODO For You localization
|
||||
size = TangemBadgeSize.X6,
|
||||
color = TangemBadgeColor.Gray,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
|
||||
@Preview(name = "Light", showBackground = true, widthDp = 360)
|
||||
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun TokenSummaryContentPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
TokenSummaryContent(
|
||||
tokenSummary = previewTokenSummary(
|
||||
periodPickerUm = PeriodPickerUM.Content(
|
||||
TangemSegmentedPickerUM(
|
||||
items = persistentListOf(
|
||||
TangemSegmentUM(id = "0", title = stringReference("Day")),
|
||||
TangemSegmentUM(id = "1", title = stringReference("Week")),
|
||||
TangemSegmentUM(id = "2", title = stringReference("Month")),
|
||||
),
|
||||
initialSelectedItem = TangemSegmentUM(id = "0", title = stringReference("Day")),
|
||||
isFixed = true,
|
||||
isAltSurface = true,
|
||||
),
|
||||
),
|
||||
tokenSentiment = previewContentSentiment,
|
||||
),
|
||||
contentPadding = PaddingValues.Zero,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Loading · Light", showBackground = true, widthDp = 360)
|
||||
@Preview(name = "Loading · Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun TokenSummaryContentLoadingPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
TokenSummaryContent(
|
||||
tokenSummary = previewTokenSummary(
|
||||
periodPickerUm = PeriodPickerUM.Loading,
|
||||
tokenSentiment = TokenSentimentUM.Loading,
|
||||
),
|
||||
contentPadding = PaddingValues.Zero,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Empty · Light", showBackground = true, widthDp = 360)
|
||||
@Preview(name = "Empty · Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun TokenSummaryContentEmptyPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
TokenSummaryContent(
|
||||
tokenSummary = previewTokenSummary(
|
||||
periodPickerUm = PeriodPickerUM.Empty,
|
||||
tokenSentiment = TokenSentimentUM.Empty,
|
||||
),
|
||||
contentPadding = PaddingValues.Zero,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation.ContentAlign
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.InfoBottomSheetContent
|
||||
|
||||
/**
|
||||
* Informational modal bottom sheet for the token summary screen.
|
||||
*
|
||||
* Renders the [infoBottomSheetContent]: a [title][InfoBottomSheetContent.title] with a trailing close (`✕`) button in the top
|
||||
* navigation, and a scrollable explanatory [body][InfoBottomSheetContent.body]. Visibility is driven by the hosting
|
||||
* Decompose slot, so the config is always shown; [onDismiss] delegates back to the slot navigation.
|
||||
*
|
||||
* @param infoBottomSheetContent the info content to display.
|
||||
* @param onDismiss invoked when the sheet is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
internal fun InfoBottomSheet(infoBottomSheetContent: InfoBottomSheetContent, onDismiss: () -> Unit) {
|
||||
TangemBottomSheet<InfoBottomSheetContent>(
|
||||
config = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = onDismiss,
|
||||
content = infoBottomSheetContent,
|
||||
),
|
||||
type = TangemBottomSheetType.Modal,
|
||||
containerColor = TangemTheme.colors3.bg.primary,
|
||||
title = { content ->
|
||||
TangemTopNavigation(
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
contentAlign = ContentAlign.Center,
|
||||
endButton = { TangemButton.Close(onClick = onDismiss) },
|
||||
contentColumn = {
|
||||
Text(
|
||||
text = content.title.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
content = { content ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(all = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = content.body.resolveReference(),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.ui.components
|
||||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.ds.image.TangemIcon
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds2.button.Close
|
||||
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
|
||||
import com.tangem.core.ui.ds2.button.TangemButton
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
|
||||
|
||||
/**
|
||||
* Top navigation ("Nav bar") for the token summary screen.
|
||||
*
|
||||
* Built on the redesigned [TangemTopNavigation]: a leading [currency icon][CurrencyIconState] in the start slot, a
|
||||
* [title][TokenSummaryHeaderUM.title] over a [subtitle][TokenSummaryHeaderUM.subtitle] in the center slot, and a
|
||||
* trailing close (`✕`) button in the end slot. Title and subtitle are single-line and ellipsized on overflow.
|
||||
*
|
||||
* Hosted inside a modal bottom sheet, so [WindowInsets] is zeroed (no status-bar reservation) and the background blur
|
||||
* is disabled.
|
||||
*
|
||||
* @param header content of the navigation bar — the currency icon, title and subtitle to display.
|
||||
* @param onCloseClick invoked when the trailing close button is tapped.
|
||||
* @param modifier [Modifier] applied to the root navigation bar.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TokenSummaryTopNavigation(
|
||||
header: TokenSummaryHeaderUM,
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClick: () -> Unit,
|
||||
) {
|
||||
TangemTopNavigation(
|
||||
modifier = modifier,
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
startButton = {
|
||||
TangemIcon(
|
||||
tangemIconUM = header.tangemIconUM,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
},
|
||||
endButton = {
|
||||
TangemButton.Close(
|
||||
onClick = onCloseClick,
|
||||
)
|
||||
},
|
||||
contentColumn = {
|
||||
Text(
|
||||
text = header.title.resolveReference(),
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (header.subtitle != null) {
|
||||
Text(
|
||||
text = header.subtitle.resolveReference(),
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
|
||||
@Preview(name = "Light", showBackground = true, widthDp = 360)
|
||||
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
|
||||
@Composable
|
||||
private fun TokenSummaryTopNavigationPreview() {
|
||||
TangemThemePreviewRedesign {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors3.bg.primary),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TokenSummaryTopNavigation(
|
||||
header = previewHeader(
|
||||
title = stringReference("Ethereum"),
|
||||
subtitle = stringReference("ETH"),
|
||||
),
|
||||
onCloseClick = {},
|
||||
)
|
||||
TokenSummaryTopNavigation(
|
||||
header = previewHeader(
|
||||
title = stringReference("Ethereum"),
|
||||
subtitle = null,
|
||||
),
|
||||
onCloseClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewHeader(title: TextReference, subtitle: TextReference?) = TokenSummaryHeaderUM(
|
||||
tangemIconUM = TangemIconUM.Currency(
|
||||
CurrencyIconState.CustomTokenIcon(
|
||||
tint = TangemColorPalette.Black,
|
||||
background = TangemColorPalette.Meadow,
|
||||
topBadgeIconResId = R.drawable.img_polygon_22,
|
||||
isGrayscale = false,
|
||||
),
|
||||
),
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package com.tangem.features.foryou.impl.tokensummary.ui.preivew
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeColor
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeShape
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeSize
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeType
|
||||
import com.tangem.core.ui.ds.badge.TangemBadgeUM
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.IndicatorType
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.PeriodPickerUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenIndicatorUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSentimentUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryHeaderUM
|
||||
import com.tangem.features.foryou.impl.tokensummary.entity.TokenSummaryUm
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
internal fun previewTokenSummary(periodPickerUm: PeriodPickerUM, tokenSentiment: TokenSentimentUM) = TokenSummaryUm(
|
||||
header = TokenSummaryHeaderUM(
|
||||
tangemIconUM = TangemIconUM.Currency(
|
||||
CurrencyIconState.CustomTokenIcon(
|
||||
tint = TangemColorPalette.Black,
|
||||
background = TangemColorPalette.Meadow,
|
||||
topBadgeIconResId = R.drawable.img_polygon_22,
|
||||
isGrayscale = false,
|
||||
),
|
||||
),
|
||||
title = stringReference("Ethereum"),
|
||||
subtitle = stringReference("ETH"),
|
||||
),
|
||||
tokenSentiment = tokenSentiment,
|
||||
periodPicker = periodPickerUm,
|
||||
aiInsight = AiInsightUM.Displayed(
|
||||
"Your portfolio leans on a single asset – BTC is 42% of holdings. Stablecoins add 23% " +
|
||||
"buffer. Consider trimming concentration for a smoother ride",
|
||||
),
|
||||
onPeriodClick = {},
|
||||
onCloseClick = {},
|
||||
onSwapClick = {},
|
||||
onInfoClick = {},
|
||||
)
|
||||
|
||||
internal val previewContentSentiment = TokenSentimentUM.Content(
|
||||
sentiment = stringReference("Negative outlook"),
|
||||
lastUpdate = stringReference("Updated Jan 20 2026, 9:24 PM"),
|
||||
totalScore = -4,
|
||||
indicators = persistentListOf(
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Neutral"),
|
||||
color = TangemBadgeColor.Blue,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.GalaxyScore,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Positive"),
|
||||
color = TangemBadgeColor.Green,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.Sentiment,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.RSI,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.MACD,
|
||||
),
|
||||
TokenIndicatorUM.Content(
|
||||
sentimentBadge = TangemBadgeUM(
|
||||
text = stringReference("Negative"),
|
||||
color = TangemBadgeColor.Red,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
scoreBadge = TangemBadgeUM(
|
||||
text = stringReference("72.21"),
|
||||
color = TangemBadgeColor.Gray,
|
||||
size = TangemBadgeSize.X6,
|
||||
type = TangemBadgeType.Tinted,
|
||||
shape = TangemBadgeShape.Rounded,
|
||||
),
|
||||
indicatorType = IndicatorType.MA_CROSS,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.tangem.features.foryou.impl.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.ds.button.SecondaryTangemButton
|
||||
import com.tangem.core.ui.ds.button.TangemButtonSize
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.features.foryou.impl.R
|
||||
import com.tangem.features.foryou.impl.components.CanvasGradientDivider
|
||||
import com.tangem.features.foryou.impl.components.state.AiInsightUM
|
||||
|
||||
@Suppress("ModifierHeightWithText")
|
||||
@Composable
|
||||
internal fun AiInsightContent(aiInsightUM: AiInsightUM, modifier: Modifier = Modifier) {
|
||||
AnimatedContent(
|
||||
targetState = aiInsightUM,
|
||||
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
|
||||
) { currentState ->
|
||||
when (currentState) {
|
||||
is AiInsightUM.AskAiInsight -> {
|
||||
SecondaryTangemButton(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
onClick = currentState.askAiInsightClick,
|
||||
size = TangemButtonSize.X9,
|
||||
text = resourceReference(R.string.market_chart_ask_for_ai_summary_button),
|
||||
)
|
||||
}
|
||||
is AiInsightUM.Displayed -> {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(IntrinsicSize.Min),
|
||||
) {
|
||||
CanvasGradientDivider(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp),
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
brush = Brush.horizontalGradient(
|
||||
listOf(
|
||||
TangemTheme.colors3.icon.accent.violet,
|
||||
TangemTheme.colors3.icon.accent.blue,
|
||||
),
|
||||
),
|
||||
alpha = 1f,
|
||||
),
|
||||
) { append(stringResourceSafe(R.string.market_chart_ai_total)) }
|
||||
append(" ")
|
||||
append(currentState.text)
|
||||
},
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
AiInsightUM.Hide -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue