Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-20 13:05:29 +01:00
parent 5e10c943a0
commit 4d1902af26
3 changed files with 447 additions and 56 deletions

View file

@ -0,0 +1,244 @@
package com.tangem.core.ui.ds.opportunities
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.res.LocalIsInDarkTheme
import com.tangem.core.ui.res.TangemThemePreview
import dev.chrisbanes.haze.HazeStyle
/**
* Container that draws a blurred background (from URL or solid color) and
* applies a semitransparent overlay on top of it, then renders foreground content.
*
* Figma https://www.figma.com/design/X0IMgSMOT5rWWgiSIeZQwC/Bottom-sheet--Redesign-?node-id=3360-64755&m=dev
*
* @param icon Background configuration (URL, solid color or none).
* @param modifier Modifier applied to the outer container.
* @param content Foreground content rendered on top of the overlay.
* @param shape Shape used for inner shadow and border (e.g. rounded corners).
*/
@Suppress("MagicNumber")
@Composable
fun OpportunitiesBG(
icon: TangemIconUM,
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp),
content: @Composable BoxScope.() -> Unit,
) {
val isInDarkTheme = LocalIsInDarkTheme.current
val overlayColor = remember(isInDarkTheme) {
if (isInDarkTheme) {
Color(OVERLAY_DARK)
} else {
Color.White
}
}
Box(modifier = modifier) {
BackgroundLayer(icon = icon)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.innerShadow(
shape = shape,
shadow = Shadow(
radius = 30.dp,
spread = 5.dp,
color = Color(INNER_SHADOW_COLOR_START).copy(alpha = .3f),
offset = DpOffset(0.dp, 0.dp),
),
)
.innerShadow(
shape = shape,
shadow = Shadow(
radius = 100.dp,
spread = (-39).dp,
color = Color(INNER_SHADOW_COLOR_END).copy(.3f),
offset = DpOffset(0.dp, (-56).dp),
),
)
.innerShadow(
shape = shape,
shadow = Shadow(
radius = 40.dp,
spread = (-19).dp,
color = Color(INNER_SHADOW_COLOR_END).copy(alpha = .25f),
offset = DpOffset(0.dp, (-16).dp),
),
)
.drawWithContent {
drawRect(color = overlayColor.copy(alpha = .7f))
drawContent()
val outline = shape.createOutline(size, layoutDirection, this)
drawOutline(outline, Color(BORDER_COLOR).copy(alpha = .1f), style = Stroke(width = 1.dp.toPx()))
},
content = content,
)
}
}
@Suppress("CyclomaticComplexMethod")
@Composable
private fun BoxScope.BackgroundLayer(icon: TangemIconUM, blurRadius: Dp = 26.dp) {
when (icon) {
is TangemIconUM.Currency -> CurrencyIconBackgroundLayer(icon.currencyIconState, blurRadius)
is TangemIconUM.Icon -> SolidColorBackground(icon.tintReference(), blurRadius)
is TangemIconUM.Ident -> Unit
is TangemIconUM.Image -> ResBackground(icon.imageRes, blurRadius)
}
}
@Suppress("CyclomaticComplexMethod")
@Composable
private fun BoxScope.CurrencyIconBackgroundLayer(state: CurrencyIconState, blurRadius: Dp) {
when (state) {
is CurrencyIconState.CryptoPortfolio.Icon -> SolidColorBackground(
color = state.color,
blurRadius = blurRadius,
)
is CurrencyIconState.CryptoPortfolio.Letter -> SolidColorBackground(
color = state.color,
blurRadius = blurRadius,
)
is CurrencyIconState.CustomTokenIcon -> SolidColorBackground(
color = state.background,
blurRadius = blurRadius,
)
is CurrencyIconState.Empty -> ResBackground(res = state.resId, blurRadius = blurRadius)
is CurrencyIconState.CoinIcon -> {
state.url?.let {
UrlBackground(imageUrl = state.url, blurRadius = blurRadius)
} ?: run {
ResBackground(res = state.fallbackResId, blurRadius = blurRadius)
}
}
is CurrencyIconState.FiatIcon -> state.url?.let {
UrlBackground(imageUrl = state.url, blurRadius = blurRadius)
} ?: run {
ResBackground(res = state.fallbackResId, blurRadius = blurRadius)
}
is CurrencyIconState.TokenIcon -> state.url?.let {
UrlBackground(imageUrl = state.url, blurRadius = blurRadius)
} ?: run {
SolidColorBackground(
color = state.fallbackBackground,
blurRadius = blurRadius,
)
}
CurrencyIconState.Loading -> Unit
CurrencyIconState.Locked -> Unit
}
}
@Composable
private fun BoxScope.UrlBackground(imageUrl: String?, blurRadius: Dp) {
val context = LocalContext.current
val imageRequest = remember(imageUrl) {
if (imageUrl.isNullOrBlank()) {
null
} else {
ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(true)
.build()
}
}
if (imageRequest != null) {
AsyncImage(
model = imageRequest,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.scale(SCALE_FACTOR)
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = blurRadius, tint = null)),
)
}
}
@Composable
private fun BoxScope.ResBackground(res: Int, blurRadius: Dp) {
Image(
painter = painterResource(res),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.scale(SCALE_FACTOR)
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = blurRadius, tint = null)),
)
}
@Composable
private fun BoxScope.SolidColorBackground(color: Color, blurRadius: Dp) {
Box(
modifier = Modifier
.matchParentSize()
.background(color = color)
.hazeForegroundEffectTangem(style = HazeStyle(blurRadius = blurRadius, tint = null)),
)
}
private const val SCALE_FACTOR = 1.5f
private const val INNER_SHADOW_COLOR_START = 0x00000000
private const val INNER_SHADOW_COLOR_END = 0xFFFFFFFF
private const val BORDER_COLOR = 0xF0F0F0
private const val OVERLAY_DARK = 0x141414
// region Previews
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun OpportunitiesBGPreview() {
TangemThemePreview {
OpportunitiesBG(
modifier = Modifier.size(400.dp),
icon = TangemIconUM.Currency(
CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_solana_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
content = {},
)
}
}
// endregion

View file

@ -3,7 +3,6 @@ package com.tangem.features.feed.ui.earn
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.Icon
@ -14,21 +13,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
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.*
import com.tangem.core.ui.components.SmallButtonShimmer
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
@ -39,6 +38,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.feed.ui.earn.components.EarnItemPlaceholder
import com.tangem.features.feed.ui.earn.components.EarnListItem
import com.tangem.features.feed.ui.earn.components.MostlyUsedCard
import com.tangem.features.feed.ui.earn.components.MostlyUsedPlaceholder
import com.tangem.features.feed.ui.earn.state.*
import kotlinx.collections.immutable.persistentListOf
@ -161,57 +161,6 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
}
}
@Composable
private fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(148.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(onClick = onClick)
.padding(12.dp),
) {
CurrencyIcon(
modifier = Modifier.size(32.dp),
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = 12.dp,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors.text.accent,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Composable
private fun BestOpportunitiesFilters(
state: EarnBestOpportunitiesUM,

View file

@ -0,0 +1,198 @@
package com.tangem.features.feed.ui.earn.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
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.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.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.ds.opportunities.OpportunitiesBG
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.*
import com.tangem.features.feed.ui.earn.state.EarnListItemUM
@Composable
internal fun MostlyUsedCard(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
val isRedesignEnabled = LocalRedesignEnabled.current
if (isRedesignEnabled) {
MostlyUsedCardV2(
modifier = modifier,
item = item,
onClick = onClick,
)
} else {
MostlyUsedCardV1(
modifier = modifier,
item = item,
onClick = onClick,
)
}
}
@Composable
private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
OpportunitiesBG(
modifier = modifier
.width(148.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.clickable(onClick = onClick),
icon = TangemIconUM.Currency(item.currencyIconState),
) {
Column(modifier = Modifier.padding(12.dp)) {
CurrencyIcon(
modifier = Modifier.size(32.dp),
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = 12.dp,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(22.dp)
Row(
verticalAlignment = Alignment.Bottom,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.bodySemibold16,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors2.text.status.positive,
style = TangemTheme.typography2.captionSemibold12,
maxLines = 1,
)
}
}
}
@Composable
private fun MostlyUsedCardV1(item: EarnListItemUM, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(148.dp)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(onClick = onClick)
.padding(12.dp),
) {
CurrencyIcon(
modifier = Modifier.size(32.dp),
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = 12.dp,
networkBadgeBackground = TangemTheme.colors.background.action,
)
SpacerH(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = item.tokenName.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
SpacerW(4.dp)
Text(
text = item.symbol.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.subtitle2,
maxLines = 1,
)
}
SpacerH(2.dp)
Text(
text = item.earnValue.resolveReference(),
color = TangemTheme.colors.text.accent,
style = TangemTheme.typography.caption1,
maxLines = 1,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun EarnListItemPreviewV1() {
TangemThemePreview {
MostlyUsedCardV1(
EarnListItemUM(
network = stringReference("Ethereum"),
symbol = stringReference("USDT"),
tokenName = stringReference("Tether"),
currencyIconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
onItemClick = {},
),
onClick = {},
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun EarnListItemPreviewV2() {
TangemThemePreviewRedesign {
MostlyUsedCardV2(
EarnListItemUM(
network = stringReference("Ethereum"),
symbol = stringReference("USDT"),
tokenName = stringReference("Tether"),
currencyIconState = CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_eth_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
earnValue = stringReference("APY 6.54%"),
earnType = stringReference("Yield"),
onItemClick = {},
),
onClick = {},
)
}
}