Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-22 15:03:36 +03:00
parent 5e3da8cc47
commit bc3443c479
11 changed files with 492 additions and 133 deletions

View file

@ -6,24 +6,33 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.earn.EarnBlockUM.Type
import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
@ -47,9 +56,12 @@ import com.tangem.core.res.R as CoreResR
private const val TINTED_BACKGROUND_ALPHA = 0.1f
private const val TINTED_BORDER_ALPHA = 0.1f
private const val TINTED_INNER_SHADOW_ALPHA = 0.3f
private const val GLOW_ALPHA = 0.7f
private val BorderWidth = 1.dp
private val InnerShadowBlur = 20.dp
private val ShimmerSubtitleWidth = 78.dp
private val LoaderSize = 12.dp
private val LoaderStrokeWidth = 1.5.dp
@Composable
fun EarnBlock(state: EarnBlockUM, modifier: Modifier = Modifier) {
@ -95,92 +107,87 @@ private fun EarnBlockLoading(modifier: Modifier = Modifier) {
private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(TangemTheme.dimens2.x4)
val backgroundModifier = resolveBackgroundModifier(state.backgroundUM, shape)
val clickModifier = when (val trailing = state.trailingUM) {
is EarnBlockUM.TrailingUM.Balance -> Modifier.clickable(onClick = trailing.onClick)
else -> Modifier
}
val clickModifier = state.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
TangemRowContainer(
modifier = modifier
.clip(shape)
.then(backgroundModifier)
.then(clickModifier),
.then(clickModifier.backgroundModifier(state.type, state.backgroundUM, shape)),
contentPadding = PaddingValues(all = TangemTheme.dimens2.x3),
content = {
// Icon (HEAD)
EarnBlockIcon(
type = state.type,
iconUM = state.iconUM,
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3),
)
// Title (START_TOP)
Text(
text = state.titleUM.text.resolveReference(),
style = when (state.titleUM.style) {
EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16
EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12
},
color = state.titleUM.color(),
style = state.titleUM.style.textStyle,
color = state.titleUM.tone.color(state.type),
modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.padding(end = TangemTheme.dimens2.x2),
)
// Subtitle (START_BOTTOM)
val subtitle = state.subtitleUM
if (subtitle is EarnBlockUM.SubtitleUM.Text) {
Text(
text = subtitle.text.resolveReference(),
style = when (subtitle.style) {
EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16
EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12
},
color = subtitle.color(),
EarnBlockSubtitle(
subtitle = subtitle,
type = state.type,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(end = TangemTheme.dimens2.x2),
)
}
// Trailing
EarnBlockTrailing(trailingUM = state.trailingUM)
EarnBlockTrailing(type = state.type, trailingUM = state.trailingUM, onClick = state.onClick)
},
)
}
@Composable
private fun resolveBackgroundModifier(backgroundUM: EarnBlockUM.BackgroundUM, shape: RoundedCornerShape): Modifier {
private fun Modifier.backgroundModifier(
type: Type,
backgroundUM: EarnBlockUM.BackgroundUM,
shape: RoundedCornerShape,
): Modifier {
return when (backgroundUM) {
is EarnBlockUM.BackgroundUM.Surface -> Modifier
is EarnBlockUM.BackgroundUM.Surface -> this
.background(TangemTheme.colors2.surface.level3)
.border(width = BorderWidth, color = TangemTheme.colors2.border.neutral.primary, shape = shape)
is EarnBlockUM.BackgroundUM.Tinted -> {
val tintColor = backgroundUM.color()
Modifier
.background(tintColor.copy(alpha = TINTED_BACKGROUND_ALPHA))
.border(width = BorderWidth, color = tintColor.copy(alpha = TINTED_BORDER_ALPHA), shape = shape)
.innerShadow(
shape = shape,
shadow = Shadow(
radius = InnerShadowBlur,
color = tintColor.copy(alpha = TINTED_INNER_SHADOW_ALPHA),
offset = DpOffset.Zero,
),
)
}
is EarnBlockUM.BackgroundUM.AccentSoft -> tintedBackground(type.accentSoftTint(), shape)
is EarnBlockUM.BackgroundUM.AccentStrong -> tintedBackground(type.accentStrongTint(), shape)
}
}
private fun Modifier.tintedBackground(tintColor: Color, shape: RoundedCornerShape): Modifier = this
.background(tintColor.copy(alpha = TINTED_BACKGROUND_ALPHA))
.border(width = BorderWidth, color = tintColor.copy(alpha = TINTED_BORDER_ALPHA), shape = shape)
.innerShadow(
shape = shape,
shadow = Shadow(
radius = InnerShadowBlur,
color = tintColor.copy(alpha = TINTED_INNER_SHADOW_ALPHA),
offset = DpOffset.Zero,
),
)
@Composable
private fun EarnBlockTrailing(trailingUM: EarnBlockUM.TrailingUM?) {
private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, onClick: (() -> Unit)?) {
when (trailingUM) {
is EarnBlockUM.TrailingUM.Button -> {
TangemButton(
buttonUM = trailingUM.buttonUM,
buttonUM = TangemButtonUM(
text = trailingUM.text,
type = type.buttonType(),
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
isEnabled = trailingUM.isEnabled,
onClick = onClick ?: {},
),
modifier = Modifier.layoutId(TangemRowLayoutId.TAIL),
)
}
@ -200,12 +207,43 @@ private fun EarnBlockTrailing(trailingUM: EarnBlockUM.TrailingUM?) {
)
}
}
is EarnBlockUM.TrailingUM.Icon -> {
TangemIcon(
tangemIconUM = TangemIconUM.Icon(
iconRes = trailingUM.tone.iconRes(),
tintReference = { trailingUM.tone.tint() },
),
modifier = Modifier
.layoutId(TangemRowLayoutId.TAIL)
.size(TangemTheme.dimens2.x6),
)
}
null -> Unit
}
}
@Composable
private fun EarnBlockIcon(iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) {
private fun EarnBlockSubtitle(subtitle: EarnBlockUM.SubtitleUM.Text, type: Type, modifier: Modifier = Modifier) {
val textStyle = subtitle.style.textStyle
val textColor = subtitle.tone.color(type)
if (subtitle.loader == null) {
Text(text = subtitle.text.resolveReference(), style = textStyle, color = textColor, modifier = modifier)
return
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Text(text = subtitle.text.resolveReference(), style = textStyle, color = textColor)
Spacer(modifier = Modifier.width(3.dp))
CircularProgressIndicator(
color = subtitle.loader.tone.color(),
strokeWidth = LoaderStrokeWidth,
strokeCap = StrokeCap.Round,
modifier = Modifier.size(LoaderSize),
)
}
}
@Composable
private fun EarnBlockIcon(type: Type, iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(TangemTheme.dimens2.x10),
@ -216,7 +254,7 @@ private fun EarnBlockIcon(iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modif
modifier = Modifier
.size(TangemTheme.dimens2.x6)
.blur(radius = TangemTheme.dimens2.x4, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.background(color = iconUM.glowColor().copy(alpha = 0.7f), shape = glowShape),
.background(color = type.accentGlow().copy(alpha = GLOW_ALPHA), shape = glowShape),
)
}
val iconRes = when (iconUM) {
@ -230,6 +268,93 @@ private fun EarnBlockIcon(iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modif
}
}
// region Type → theme mapping
@Composable
@ReadOnlyComposable
private fun Type.accentText(): Color = when (this) {
Type.Staking -> TangemTheme.colors2.text.status.accent
Type.YieldSupply -> TangemTheme.colors2.text.status.positive
}
@Composable
@ReadOnlyComposable
private fun Type.accentGlow(): Color = when (this) {
Type.Staking -> TangemTheme.colors2.border.status.accent
Type.YieldSupply -> TangemTheme.colors2.text.status.positive
}
@Composable
@ReadOnlyComposable
private fun Type.accentSoftTint(): Color = when (this) {
Type.Staking -> TangemTheme.colors2.markers.backgroundTintedBlue
Type.YieldSupply -> TangemTheme.colors2.markers.backgroundTintedGreen
}
@Composable
@ReadOnlyComposable
private fun Type.accentStrongTint(): Color = when (this) {
Type.Staking -> TangemTheme.colors2.text.status.accent
Type.YieldSupply -> TangemTheme.colors2.text.status.positive
}
private fun Type.buttonType(): TangemButtonType = when (this) {
Type.Staking -> TangemButtonType.Accent
Type.YieldSupply -> TangemButtonType.Positive
}
@Composable
@ReadOnlyComposable
private fun EarnBlockUM.TitleUM.Tone.color(type: Type): Color = when (this) {
EarnBlockUM.TitleUM.Tone.Primary -> TangemTheme.colors2.text.neutral.primary
EarnBlockUM.TitleUM.Tone.Secondary -> TangemTheme.colors2.text.neutral.secondary
EarnBlockUM.TitleUM.Tone.Disabled -> TangemTheme.colors2.text.neutral.tertiary
EarnBlockUM.TitleUM.Tone.Accent -> type.accentText()
}
@Composable
@ReadOnlyComposable
private fun EarnBlockUM.SubtitleUM.Tone.color(type: Type): Color = when (this) {
EarnBlockUM.SubtitleUM.Tone.Primary -> TangemTheme.colors2.text.neutral.primary
EarnBlockUM.SubtitleUM.Tone.Disabled -> TangemTheme.colors2.text.neutral.tertiary
EarnBlockUM.SubtitleUM.Tone.Accent -> type.accentText()
}
private fun EarnBlockUM.TrailingUM.IconTone.iconRes(): Int = when (this) {
EarnBlockUM.TrailingUM.IconTone.Warning -> R.drawable.ic_alert_triangle_20
EarnBlockUM.TrailingUM.IconTone.Info -> R.drawable.ic_alert_circle_red_20
}
@Composable
@ReadOnlyComposable
private fun EarnBlockUM.TrailingUM.IconTone.tint(): Color = when (this) {
EarnBlockUM.TrailingUM.IconTone.Warning -> TangemTheme.colors2.graphic.status.attention
EarnBlockUM.TrailingUM.IconTone.Info -> TangemTheme.colors2.fill.neutral.secondary
}
@Composable
@ReadOnlyComposable
private fun EarnBlockUM.SubtitleUM.LoaderTone.color(): Color = when (this) {
EarnBlockUM.SubtitleUM.LoaderTone.Positive -> TangemTheme.colors2.text.status.positive
EarnBlockUM.SubtitleUM.LoaderTone.Muted -> TangemTheme.colors2.graphic.neutral.tertiaryConstant
}
private val EarnBlockUM.TitleUM.Style.textStyle: TextStyle
@Composable
@ReadOnlyComposable
get() = when (this) {
EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16
EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12
}
private val EarnBlockUM.SubtitleUM.Style.textStyle: TextStyle
@Composable
@ReadOnlyComposable
get() = when (this) {
EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16
EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12
}
// endregion
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@ -247,68 +372,79 @@ private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider<Earn
collection = listOf(
EarnBlockUM.Loading,
EarnBlockUM.Content(
type = Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_native),
style = EarnBlockUM.TitleUM.Style.Large,
color = { TangemTheme.colors2.text.neutral.tertiary },
tone = EarnBlockUM.TitleUM.Tone.Disabled,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.staking_notification_network_error_text),
style = EarnBlockUM.SubtitleUM.Style.Small,
color = { TangemTheme.colors2.text.neutral.tertiary },
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = null,
),
EarnBlockUM.Content(
backgroundUM = EarnBlockUM.BackgroundUM.Tinted { TangemTheme.colors2.border.status.accent },
iconUM = EarnBlockUM.IconUM.Glowing(
iconRes = R.drawable.ic_staking_40,
glowColor = { TangemTheme.colors2.border.status.accent },
),
type = Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.token_details_staking_block_title),
style = EarnBlockUM.TitleUM.Style.Small,
color = { TangemTheme.colors2.text.status.accent },
tone = EarnBlockUM.TitleUM.Tone.Accent,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stringReference("Average APR 5.24%"),
style = EarnBlockUM.SubtitleUM.Style.Large,
color = { TangemTheme.colors2.text.neutral.primary },
tone = EarnBlockUM.SubtitleUM.Tone.Primary,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
buttonUM = TangemButtonUM(
text = stringReference("Stake"),
type = TangemButtonType.Accent,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
onClick = {},
),
text = stringReference("Stake"),
),
onClick = {},
),
EarnBlockUM.Content(
type = Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(
iconRes = R.drawable.ic_staking_40,
glowColor = { TangemTheme.colors2.text.status.accent },
),
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = stringReference("Native staking"),
style = EarnBlockUM.TitleUM.Style.Large,
color = { TangemTheme.colors2.text.neutral.primary },
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stringReference("$ 12.34 rewards"),
style = EarnBlockUM.SubtitleUM.Style.Small,
color = { TangemTheme.colors2.text.status.accent },
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Balance(
fiatValue = stringReference("$ 500.17"),
cryptoValue = stringReference("500.00 SOL"),
isBalanceHidden = false,
onClick = {},
),
onClick = {},
),
EarnBlockUM.Content(
type = Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = stringReference("Earn yield"),
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Accent,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stringReference("Start earning · 5.24%"),
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Primary,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
text = stringReference("More"),
),
onClick = {},
),
),
)

View file

@ -2,8 +2,6 @@ package com.tangem.common.ui.earn
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.ColorReference2
import com.tangem.core.ui.extensions.TextReference
@Immutable
@ -12,38 +10,38 @@ sealed interface EarnBlockUM {
data object Loading : EarnBlockUM
data class Content(
val type: Type,
val backgroundUM: BackgroundUM,
val iconUM: IconUM,
val titleUM: TitleUM,
val subtitleUM: SubtitleUM?,
val trailingUM: TrailingUM?,
val onClick: (() -> Unit)? = null,
) : EarnBlockUM
enum class Type { Staking, YieldSupply }
@Immutable
sealed interface BackgroundUM {
data object Surface : BackgroundUM
data class Tinted(val color: ColorReference2) : BackgroundUM
data object AccentSoft : BackgroundUM
data object AccentStrong : BackgroundUM
}
@Immutable
sealed interface IconUM {
data class Glowing(
@DrawableRes val iconRes: Int,
val glowColor: ColorReference2,
) : IconUM
data class Plain(
@DrawableRes val iconRes: Int,
) : IconUM
data class Glowing(@DrawableRes val iconRes: Int) : IconUM
data class Plain(@DrawableRes val iconRes: Int) : IconUM
}
@Immutable
data class TitleUM(
val text: TextReference,
val style: Style,
val color: ColorReference2,
val tone: Tone,
) {
enum class Style { Large, Small }
enum class Tone { Primary, Secondary, Disabled, Accent }
}
@Immutable
@ -51,21 +49,34 @@ sealed interface EarnBlockUM {
data class Text(
val text: TextReference,
val style: Style,
val color: ColorReference2,
val tone: Tone,
val loader: Loader? = null,
) : SubtitleUM
data class Loader(val tone: LoaderTone)
enum class Style { Large, Small }
enum class Tone { Primary, Disabled, Accent }
enum class LoaderTone { Positive, Muted }
}
@Immutable
sealed interface TrailingUM {
data class Button(val buttonUM: TangemButtonUM) : TrailingUM
data class Button(
val text: TextReference,
val isEnabled: Boolean = true,
) : TrailingUM
data class Balance(
val fiatValue: TextReference,
val cryptoValue: TextReference,
val isBalanceHidden: Boolean,
val onClick: () -> Unit,
) : TrailingUM
data class Icon(
val tone: IconTone,
) : TrailingUM
enum class IconTone { Warning, Info }
}
}

View file

@ -98,6 +98,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
TokenDetailsScreen(
tokenDetailsUM = tokenDetailsUM,
tokenMarketBlockComponent = tokenMarketBlockComponent,
yieldSupplyComponent = yieldSupplyComponent,
modifier = modifier,
)
} else {

View file

@ -4,10 +4,6 @@ import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getRewardStakingBalance
import com.tangem.common.getTotalStakingBalance
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -58,17 +54,18 @@ internal class UpdateStakingNotificationTransformer(
private fun buildTemporaryUnavailable(): EarnBlockUM.Content {
return EarnBlockUM.Content(
type = EarnBlockUM.Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_native),
style = EarnBlockUM.TitleUM.Style.Large,
color = { TangemTheme.colors2.text.neutral.tertiary },
tone = EarnBlockUM.TitleUM.Tone.Disabled,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.staking_notification_network_error_text),
style = EarnBlockUM.SubtitleUM.Style.Small,
color = { TangemTheme.colors2.text.neutral.tertiary },
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = null,
)
@ -118,31 +115,24 @@ internal class UpdateStakingNotificationTransformer(
isEnabled: Boolean,
): EarnBlockUM.Content {
return EarnBlockUM.Content(
backgroundUM = EarnBlockUM.BackgroundUM.Tinted { TangemTheme.colors2.markers.backgroundTintedBlue },
iconUM = EarnBlockUM.IconUM.Glowing(
iconRes = CoreUiR.drawable.ic_staking_40,
glowColor = { TangemTheme.colors2.border.status.accent },
),
type = EarnBlockUM.Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(id = R.string.token_details_staking_block_title),
style = EarnBlockUM.TitleUM.Style.Small,
color = { TangemTheme.colors2.text.status.accent },
tone = EarnBlockUM.TitleUM.Tone.Accent,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stakeAvailableSubtitle(availability.option.displayApy),
style = EarnBlockUM.SubtitleUM.Style.Large,
color = { TangemTheme.colors2.text.neutral.primary },
tone = EarnBlockUM.SubtitleUM.Tone.Primary,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
buttonUM = TangemButtonUM(
text = resourceReference(R.string.common_stake),
type = TangemButtonType.Accent,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
isEnabled = isEnabled,
onClick = clickIntents::onStakeBannerClick,
),
text = resourceReference(R.string.common_stake),
isEnabled = isEnabled,
),
onClick = clickIntents::onStakeBannerClick,
)
}
@ -167,15 +157,13 @@ internal class UpdateStakingNotificationTransformer(
val fiatAmount = stakingAmount?.let { fiatRate?.multiply(it) }
val rewardFiatAmount = rewardAmount?.let { fiatRate?.multiply(it) }
return EarnBlockUM.Content(
type = EarnBlockUM.Type.Staking,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(
iconRes = CoreUiR.drawable.ic_staking_40,
glowColor = { TangemTheme.colors2.border.status.accent },
),
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_native),
style = EarnBlockUM.TitleUM.Style.Large,
color = { TangemTheme.colors2.text.neutral.primary },
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = getRewardSubtitle(status, rewardFiatAmount),
trailingUM = EarnBlockUM.TrailingUM.Balance(
@ -197,8 +185,8 @@ internal class UpdateStakingNotificationTransformer(
},
),
isBalanceHidden = isBalanceHidden,
onClick = clickIntents::onStakeBannerClick,
),
onClick = clickIntents::onStakeBannerClick,
)
}
@ -262,11 +250,7 @@ internal class UpdateStakingNotificationTransformer(
return EarnBlockUM.SubtitleUM.Text(
text = text,
style = EarnBlockUM.SubtitleUM.Style.Small,
color = if (isAccent) {
{ TangemTheme.colors2.text.status.accent }
} else {
{ TangemTheme.colors2.text.neutral.tertiary }
},
tone = if (isAccent) EarnBlockUM.SubtitleUM.Tone.Accent else EarnBlockUM.SubtitleUM.Tone.Disabled,
)
}
}

View file

@ -56,6 +56,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
import kotlinx.collections.immutable.persistentListOf
@ -67,6 +68,7 @@ private val MarketBlockHorizontalPadding: Dp = 14.dp
internal fun TokenDetailsScreen(
tokenDetailsUM: TokenDetailsUM,
tokenMarketBlockComponent: TokenMarketBlockComponent?,
yieldSupplyComponent: YieldSupplyComponent,
modifier: Modifier = Modifier,
) {
val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() }
@ -107,6 +109,7 @@ internal fun TokenDetailsScreen(
body = {
TokenDetailsBody(
tokenDetailsUM = tokenDetailsUM,
yieldSupplyComponent = yieldSupplyComponent,
rootBackground = rootBackground,
bottomContentPadding = marketBlockHeight,
modifier = Modifier
@ -193,6 +196,7 @@ private fun BoxScope.TokenDetailsMarketBlockOverlay(
@Composable
private fun TokenDetailsBody(
tokenDetailsUM: TokenDetailsUM,
yieldSupplyComponent: YieldSupplyComponent,
rootBackground: Color,
bottomContentPadding: Dp,
modifier: Modifier = Modifier,
@ -215,6 +219,9 @@ private fun TokenDetailsBody(
)
}
}
item(key = "yield_supply_block") {
yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2))
}
// TODO [REDACTED_TASK_KEY] Token Details Make Transaction History
}
}
@ -262,6 +269,10 @@ private fun TokenDetailsScreen_Preview() {
isBalanceHidden = false,
isMarketPriceAvailable = true,
),
yieldSupplyComponent = object : YieldSupplyComponent {
@Composable
override fun Content(modifier: Modifier) = Unit
},
)
}
}

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.yield.supply.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Feature */
@ -20,6 +24,7 @@ dependencies {
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.decompose)
implementation(projects.core.res)
implementation(projects.core.ui)
implementation(projects.core.navigation)
implementation(projects.core.analytics)
@ -75,4 +80,11 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -4,11 +4,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.earn.EarnBlock
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import com.tangem.features.yield.supply.impl.main.model.YieldSupplyModel
import com.tangem.features.yield.supply.impl.main.ui.YieldSupplyBlockContent
import com.tangem.features.yield.supply.impl.main.ui.YieldSupplyBlockContentLegacy
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -22,9 +24,13 @@ internal class DefaultYieldSupplyComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val yieldSupplyUM by model.uiState.collectAsStateWithLifecycle()
YieldSupplyBlockContent(yieldSupplyUM = yieldSupplyUM, modifier = modifier)
if (LocalRedesignEnabled.current) {
val earnBlockUM by model.uiState.collectAsStateWithLifecycle()
earnBlockUM?.let { EarnBlock(state = it, modifier = modifier) }
} else {
val yieldSupplyUM by model.uiStateLegacy.collectAsStateWithLifecycle()
YieldSupplyBlockContentLegacy(yieldSupplyUM = yieldSupplyUM, modifier = modifier)
}
}
@AssistedFactory

View file

@ -30,7 +30,9 @@ import com.tangem.domain.yield.supply.usecase.*
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
import com.tangem.features.yield.supply.impl.R
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
import com.tangem.features.yield.supply.impl.main.model.converter.YieldSupplyToEarnBlockConverter
import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
@ -61,11 +63,16 @@ internal class YieldSupplyModel @Inject constructor(
private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase,
) : Model(), YieldSupplyClickIntents {
private val earnBlockConverter = YieldSupplyToEarnBlockConverter()
private val params = paramsContainer.require<YieldSupplyComponent.Params>()
val uiState: StateFlow<YieldSupplyUM>
val uiStateLegacy: StateFlow<YieldSupplyUM>
field = MutableStateFlow<YieldSupplyUM>(YieldSupplyUM.Initial)
val uiState: StateFlow<EarnBlockUM?> = uiStateLegacy
.map(earnBlockConverter::convert)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = null)
private val cryptoCurrency = params.cryptoCurrency
private var appCurrency: AppCurrency = AppCurrency.Default
var userWallet: UserWallet by Delegates.notNull()
@ -143,7 +150,7 @@ internal class YieldSupplyModel @Inject constructor(
val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return
yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken)
.onRight { tokenStatus ->
uiState.update(
uiStateLegacy.update(
YieldSupplyTokenStatusSuccessTransformer(
tokenStatus = tokenStatus,
onStartEarningClick = ::onStartEarningClick,
@ -151,7 +158,7 @@ internal class YieldSupplyModel @Inject constructor(
)
}.onLeft { error ->
TangemLogger.e("Error", error)
uiState.update { YieldSupplyUM.Initial }
uiStateLegacy.update { YieldSupplyUM.Initial }
}
}
@ -165,7 +172,7 @@ internal class YieldSupplyModel @Inject constructor(
private fun navigateToYieldSupplyEntry() {
val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return
val apy = when (val yieldSupplyUM = uiState.value) {
val apy = when (val yieldSupplyUM = uiStateLegacy.value) {
is YieldSupplyUM.Available -> yieldSupplyUM.apy
is YieldSupplyUM.Content -> yieldSupplyUM.apy
else -> ""
@ -181,8 +188,8 @@ internal class YieldSupplyModel @Inject constructor(
private suspend fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) {
val isCryptoCurrencyStatusFromCache = cryptoCurrencyStatus.value.sources.networkSource != StatusSource.ACTUAL
val processing = uiState.value is YieldSupplyUM.Processing
if (isCryptoCurrencyStatusFromCache && processing) {
val isProcessing = uiStateLegacy.value is YieldSupplyUM.Processing
if (isCryptoCurrencyStatusFromCache && isProcessing) {
return
}
@ -199,7 +206,7 @@ internal class YieldSupplyModel @Inject constructor(
}
private fun showProcessing(status: YieldSupplyPendingStatus) {
uiState.update {
uiStateLegacy.update {
when (status) {
is YieldSupplyPendingStatus.Enter -> YieldSupplyUM.Processing.Enter
is YieldSupplyPendingStatus.Exit -> YieldSupplyUM.Processing.Exit
@ -225,7 +232,7 @@ internal class YieldSupplyModel @Inject constructor(
) {
val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return
val showWarningIcon = !yieldSupplyStatus.isAllowedToSpend
val isShowInfoIconPrevState = when (val state = uiState.value) {
val isShowInfoIconPrevState = when (val state = uiStateLegacy.value) {
is YieldSupplyUM.Content -> state.showInfoIcon
else -> false
}
@ -239,7 +246,7 @@ internal class YieldSupplyModel @Inject constructor(
}
yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken)
.onRight { tokenStatus ->
uiState.update {
uiStateLegacy.update {
YieldSupplyUM.Content(
title = resourceReference(
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
@ -262,7 +269,7 @@ internal class YieldSupplyModel @Inject constructor(
computeAndApplyShowInfoIcon(cryptoCurrencyStatus)
}.onLeft { t ->
TangemLogger.e("Error", t)
uiState.update {
uiStateLegacy.update {
YieldSupplyUM.Content(
title = resourceReference(
R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title,
@ -299,7 +306,7 @@ internal class YieldSupplyModel @Inject constructor(
} else {
false
}
uiState.update { state ->
uiStateLegacy.update { state ->
when (state) {
is YieldSupplyUM.Content -> state.copy(showInfoIcon = isShowInfoIcon)
else -> state

View file

@ -0,0 +1,129 @@
package com.tangem.features.yield.supply.impl.main.model.converter
import com.tangem.common.ui.earn.EarnBlockUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
import com.tangem.utils.converter.Converter
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R as CoreUiR
internal class YieldSupplyToEarnBlockConverter : Converter<YieldSupplyUM, EarnBlockUM?> {
override fun convert(value: YieldSupplyUM): EarnBlockUM? = when (value) {
is YieldSupplyUM.Initial,
is YieldSupplyUM.Unavailable,
-> null
is YieldSupplyUM.Available -> buildAvailable(value)
is YieldSupplyUM.Content -> buildContent(value)
is YieldSupplyUM.Processing.Enter -> buildProcessingEnter()
is YieldSupplyUM.Processing.Exit -> buildProcessingExit()
is YieldSupplyUM.Loading -> EarnBlockUM.Loading
}
private fun buildAvailable(value: YieldSupplyUM.Available): EarnBlockUM.Content {
return EarnBlockUM.Content(
type = EarnBlockUM.Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(
id = CoreResR.string.yield_module_token_details_earn_notification_subtitle,
formatArgs = wrappedList(value.apy),
),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(
CoreResR.string.yield_module_token_details_earn_notification_description,
),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
text = resourceReference(CoreResR.string.common_more),
),
onClick = value.onClick,
)
}
private fun buildContent(value: YieldSupplyUM.Content): EarnBlockUM.Content {
return EarnBlockUM.Content(
type = EarnBlockUM.Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = if (value.showWarningIcon) {
resourceReference(CoreResR.string.common_yield_mode)
} else {
resourceReference(CoreResR.string.yield_module_transaction_enter)
},
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(
id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList(value.apy),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = buildContentTrailing(value),
onClick = value.onClick,
)
}
private fun buildContentTrailing(value: YieldSupplyUM.Content): EarnBlockUM.TrailingUM? = when {
value.showWarningIcon -> EarnBlockUM.TrailingUM.Icon(
tone = EarnBlockUM.TrailingUM.IconTone.Warning,
)
value.showInfoIcon -> EarnBlockUM.TrailingUM.Icon(
tone = EarnBlockUM.TrailingUM.IconTone.Info,
)
else -> EarnBlockUM.TrailingUM.Button(
text = resourceReference(CoreResR.string.details_title),
)
}
private fun buildProcessingEnter(): EarnBlockUM.Content {
return EarnBlockUM.Content(
type = EarnBlockUM.Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.common_enabling),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive),
),
trailingUM = null,
)
}
private fun buildProcessingExit(): EarnBlockUM.Content {
return EarnBlockUM.Content(
type = EarnBlockUM.Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_yield_disabling_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.common_disabling),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted),
),
trailingUM = null,
)
}
}

View file

@ -38,7 +38,7 @@ import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
import com.tangem.utils.StringsSigns
@Composable
internal fun YieldSupplyBlockContent(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) {
internal fun YieldSupplyBlockContentLegacy(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) {
AnimatedContent(
targetState = yieldSupplyUM,
contentKey = { it::class },
@ -330,7 +330,7 @@ private fun SupplyInfo(
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun YieldSupplyBlockContent_Preview(@PreviewParameter(PreviewProvider::class) params: YieldSupplyUM) {
TangemThemePreview {
YieldSupplyBlockContent(yieldSupplyUM = params, modifier = Modifier)
YieldSupplyBlockContentLegacy(yieldSupplyUM = params, modifier = Modifier)
}
}

View file

@ -86,6 +86,68 @@ internal class YieldSupplyToEarnBlockConverterTest {
assertThat(earnBlock.trailingUM).isNull()
}
@Test
fun `GIVEN Content with showWarningIcon WHEN convert THEN trailing Warning Icon`() {
val content = YieldSupplyUM.Content(
apy = "5.1",
title = stringReference("Yield Mode"),
subtitle = stringReference("Interest accrues automatically"),
rewardsApy = stringReference("APY 5.1%"),
onClick = {},
showWarningIcon = true,
showInfoIcon = false,
)
val result = converter.convert(content)
assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java)
val earnBlock = result as EarnBlockUM.Content
assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java)
val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon
assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Warning)
}
@Test
fun `GIVEN Content with showInfoIcon WHEN convert THEN trailing Info Icon`() {
val content = YieldSupplyUM.Content(
apy = "5.1",
title = stringReference("Yield Mode"),
subtitle = stringReference("Interest accrues automatically"),
rewardsApy = stringReference("APY 5.1%"),
onClick = {},
showWarningIcon = false,
showInfoIcon = true,
)
val result = converter.convert(content)
assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java)
val earnBlock = result as EarnBlockUM.Content
assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java)
val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon
assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Info)
}
@Test
fun `GIVEN Content with both icons WHEN convert THEN Warning takes precedence`() {
val content = YieldSupplyUM.Content(
apy = "5.1",
title = stringReference("Yield Mode"),
subtitle = stringReference("Interest accrues automatically"),
rewardsApy = stringReference("APY 5.1%"),
onClick = {},
showWarningIcon = true,
showInfoIcon = true,
)
val result = converter.convert(content)
val earnBlock = result as EarnBlockUM.Content
assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java)
val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon
assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Warning)
}
@Test
fun `GIVEN Available WHEN convert THEN Content with expected structure`() {
var clicked = false