diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt index 916fe0e737..e23fd4042e 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -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 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 } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 035e4511bf..c2f0d12ba7 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -98,6 +98,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( TokenDetailsScreen( tokenDetailsUM = tokenDetailsUM, tokenMarketBlockComponent = tokenMarketBlockComponent, + yieldSupplyComponent = yieldSupplyComponent, modifier = modifier, ) } else { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt index abb2d6d3d3..46fbf2bac8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -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, ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 0783260183..ac99df1076 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -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 + }, ) } } diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index d71b9cf36a..4bf8eda135 100644 --- a/features/yield-supply/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.yield.supply.impl" } +tasks.withType().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) } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt index 2473062b59..15c94cd9ac 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt @@ -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 diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 70194d75cc..03dbe46227 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -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() - val uiState: StateFlow + val uiStateLegacy: StateFlow field = MutableStateFlow(YieldSupplyUM.Initial) + val uiState: StateFlow = 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 diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt new file mode 100644 index 0000000000..1a86207510 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt @@ -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 { + + 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, + ) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt similarity index 98% rename from features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt rename to features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt index 36a30575b7..7286fa3c1e 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt @@ -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) } } diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt index e50df7c3c7..3a44708e54 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt @@ -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