Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-24 13:48:16 +03:00
parent 9836b18ff4
commit 050c7b6106
13 changed files with 369 additions and 222 deletions

View file

@ -2,12 +2,21 @@ package com.tangem.core.ui.components.account
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -15,7 +24,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
@ -36,14 +47,28 @@ enum class AccountIconSize {
*/
@Composable
fun AccountResIcon(@DrawableRes resId: Int, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) {
val boxModifier = modifier.selectBoxModifier(size)
val iconSize = Modifier.selectIconSize(size)
val boxSize by animateDpAsState(
targetValue = size.boxSizeInDp(),
animationSpec = animation(),
)
val boxShapeCornerSize by animateDpAsState(
targetValue = size.boxShapeSizeInDp(),
animationSpec = animation(),
)
val iconSize by animateDpAsState(
targetValue = size.iconSizeInDp(),
animationSpec = animation(),
)
Box(
contentAlignment = Alignment.Center,
modifier = boxModifier.background(color),
modifier = modifier
.size(boxSize)
.clip(RoundedCornerShape(boxShapeCornerSize))
.background(color),
) {
Icon(
modifier = iconSize,
modifier = Modifier.size(iconSize),
tint = TangemTheme.colors.text.constantWhite,
imageVector = ImageVector.vectorResource(id = resId),
contentDescription = null,
@ -64,7 +89,14 @@ fun AccountResIcon(@DrawableRes resId: Int, color: Color, size: AccountIconSize,
*/
@Composable
fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: Modifier = Modifier) {
val boxModifier = modifier.selectBoxModifier(size)
val boxSize by animateDpAsState(
size.boxSizeInDp(),
animationSpec = animation(),
)
val boxShapeCornerSize by animateDpAsState(
size.boxShapeSizeInDp(),
animationSpec = animation(),
)
val textStyle = when (size) {
AccountIconSize.Default -> TangemTheme.typography.h3
AccountIconSize.Large -> TangemTheme.typography.h1
@ -72,32 +104,52 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M
AccountIconSize.Small -> TangemTheme.typography.subtitle2
AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1
}
val textSize by animateFloatAsState(
textStyle.fontSize.value,
animationSpec = animation(),
)
Box(
contentAlignment = Alignment.Center,
modifier = boxModifier.background(color),
modifier = modifier
.size(boxSize)
.clip(RoundedCornerShape(boxShapeCornerSize))
.background(color),
) {
Text(
modifier = Modifier.animateContentSize(animationSpec = animation()),
text = char.uppercase(),
style = textStyle,
style = textStyle.copy(fontSize = textSize.sp),
color = TangemTheme.colors.text.constantWhite,
)
}
}
private fun Modifier.selectIconSize(size: AccountIconSize): Modifier = when (size) {
AccountIconSize.Default -> this.size(20.dp)
AccountIconSize.Large -> this.size(40.dp)
AccountIconSize.Medium -> this.size(16.dp)
AccountIconSize.Small -> this.size(12.dp)
AccountIconSize.ExtraSmall -> this.size(8.dp)
private fun <T> animation() = tween<T>(durationMillis = 350)
private fun AccountIconSize.iconSizeInDp(): Dp = when (this) {
AccountIconSize.Default -> 20.dp
AccountIconSize.Large -> 40.dp
AccountIconSize.Medium -> 16.dp
AccountIconSize.Small -> 12.dp
AccountIconSize.ExtraSmall -> 8.dp
}
private fun Modifier.selectBoxModifier(size: AccountIconSize): Modifier = when (size) {
AccountIconSize.Default -> size(36.dp).clip(RoundedCornerShape(10.dp))
AccountIconSize.Large -> size(88.dp).clip(RoundedCornerShape(24.dp))
AccountIconSize.Medium -> size(28.dp).clip(RoundedCornerShape(8.dp))
AccountIconSize.Small -> size(20.dp).clip(RoundedCornerShape(6.dp))
AccountIconSize.ExtraSmall -> size(14.dp).clip(RoundedCornerShape(4.dp))
private fun AccountIconSize.boxSizeInDp(): Dp = when (this) {
AccountIconSize.Default -> 36.dp
AccountIconSize.Large -> 88.dp
AccountIconSize.Medium -> 28.dp
AccountIconSize.Small -> 20.dp
AccountIconSize.ExtraSmall -> 14.dp
}
private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) {
AccountIconSize.Default -> 10.dp
AccountIconSize.Large -> 24.dp
AccountIconSize.Medium -> 8.dp
AccountIconSize.Small -> 6.dp
AccountIconSize.ExtraSmall -> 4.dp
}
@Preview(showBackground = true)
@ -115,7 +167,20 @@ private fun Preview() {
@Composable
private fun Sample() {
var sizeState by remember { mutableStateOf(AccountIconSize.ExtraSmall) }
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
AccountResIcon(resId = R.drawable.ic_shirt_24, color = Color.Red, size = sizeState)
Button(onClick = {
sizeState = when (sizeState) {
AccountIconSize.Default -> AccountIconSize.Large
AccountIconSize.Large -> AccountIconSize.Medium
AccountIconSize.Medium -> AccountIconSize.Small
AccountIconSize.Small -> AccountIconSize.ExtraSmall
AccountIconSize.ExtraSmall -> AccountIconSize.Default
}
}) { Text("Change") }
AccountResIcon(resId = R.drawable.ic_shirt_24, color = Color.Red, size = AccountIconSize.Default)
AccountResIcon(resId = R.drawable.ic_rounded_star_24, color = Color.Blue, size = AccountIconSize.Large)
AccountResIcon(resId = R.drawable.ic_user_24, color = Color.Magenta, size = AccountIconSize.Medium)
@ -123,6 +188,7 @@ private fun Sample() {
AccountResIcon(resId = R.drawable.ic_wallet_24, color = Color.Green, size = AccountIconSize.ExtraSmall)
}
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
AccountCharIcon(char = 'D', color = Color.Red, size = sizeState)
AccountCharIcon(char = 'D', color = Color.Red, size = AccountIconSize.Default)
AccountCharIcon(char = 'L', color = Color.Blue, size = AccountIconSize.Large)
AccountCharIcon(char = 'M', color = Color.Magenta, size = AccountIconSize.Medium)

View file

@ -14,7 +14,6 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.R
import com.tangem.core.ui.components.account.AccountCharIcon
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.AccountResIcon
import com.tangem.core.ui.components.currency.DefaultCurrencyIcon
import com.tangem.core.ui.extensions.resolveReference
@ -66,13 +65,13 @@ internal fun ContentIcon(
modifier = modifier,
resId = icon.resId,
color = icon.color,
size = AccountIconSize.Default,
size = icon.size,
)
is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon(
modifier = modifier,
char = icon.char.resolveReference().first(),
color = icon.color,
size = AccountIconSize.Default,
size = icon.size,
)
CurrencyIconState.Loading,
CurrencyIconState.Locked,

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.getGreyScaleColorFilter
@ -33,12 +34,20 @@ fun CurrencyIcon(
state: CurrencyIconState,
modifier: Modifier = Modifier,
shouldDisplayNetwork: Boolean = true,
withFixedSize: Boolean = true,
iconSize: Dp = 36.dp,
) {
BaseContainer(modifier = modifier) {
Box(
modifier = modifier
.conditional(withFixedSize) {
size(size = 40.dp)
},
) {
val iconModifier = Modifier
.align(Alignment.Center)
.size(iconSize)
.conditional(withFixedSize) {
size(iconSize)
}
when (state) {
is CurrencyIconState.Loading -> LoadingIcon(modifier = iconModifier)
@ -132,9 +141,4 @@ private fun BoxScope.ContentIconContainer(
modifier = Modifier.align(Alignment.BottomEnd),
)
}
}
@Composable
private inline fun BaseContainer(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Box(modifier = modifier.size(size = TangemTheme.dimens.size40), content = content)
}

View file

@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.R
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.extensions.TextReference
/**
@ -92,17 +93,20 @@ sealed class CurrencyIconState {
override val shouldShowCustomBadge: Boolean = false
override val topBadgeIconResId: Int? = null
abstract val color: Color
abstract val size: AccountIconSize
data class Icon(
@DrawableRes val resId: Int,
override val color: Color,
override val isGrayscale: Boolean,
override val size: AccountIconSize = AccountIconSize.Default,
) : CryptoPortfolio()
data class Letter(
val char: TextReference,
override val color: Color,
override val isGrayscale: Boolean,
override val size: AccountIconSize = AccountIconSize.Default,
) : CryptoPortfolio()
}

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
@ -31,6 +32,7 @@ import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.components.token.state.TokenItemState.Subtitle2State
import com.tangem.core.ui.components.token.state.TokenItemState.PromoBannerState
import com.tangem.core.ui.components.token.state.TokenItemState.TitleState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.rememberHapticFeedback
import com.tangem.core.ui.extensions.stringReference
@ -65,6 +67,7 @@ fun TokenItem(
state: TokenItemState,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
composables: TokenItemComposables? = null,
itemPaddingValues: PaddingValues = PaddingValues(horizontal = TangemTheme.dimens.spacing12),
) {
TokenItem(
@ -73,9 +76,16 @@ fun TokenItem(
modifier = modifier,
reorderableTokenListState = null,
itemPaddingValues = itemPaddingValues,
composables = composables,
)
}
@Stable
class TokenItemComposables(
val title: @Composable (Modifier) -> Unit,
val icon: @Composable (Modifier) -> Unit,
)
/**
* Token item for reorderable list
*
@ -88,6 +98,7 @@ fun TokenItem(
* @see <a href = "https://www.figma.com/design/14ISV23YB1yVW1uNVwqrKv/Android?node-id=1051-866&t=ew8mbGp2lacuJfFm-4"
* >Figma Component</a>
*/
@Suppress("LongMethod")
@Composable
fun TokenItem(
state: TokenItemState,
@ -95,6 +106,7 @@ fun TokenItem(
reorderableTokenListState: ReorderableLazyListState?,
modifier: Modifier = Modifier,
itemPaddingValues: PaddingValues = PaddingValues(horizontal = TangemTheme.dimens.spacing12),
composables: TokenItemComposables? = null,
) {
val betweenRowsMargin = TangemTheme.dimens.spacing2
@ -104,13 +116,22 @@ fun TokenItem(
.tokenClickable(state = state)
.padding(itemPaddingValues),
) {
CurrencyIcon(
state = state.iconState,
modifier = Modifier
.layoutId(layoutId = LayoutId.ICON)
.padding(end = TangemTheme.dimens.spacing8)
.testTag(TokenElementsTestTags.TOKEN_ICON),
)
if (composables != null) {
composables.icon.invoke(
Modifier
.layoutId(layoutId = LayoutId.ICON)
.padding(end = TangemTheme.dimens.spacing8)
.testTag(TokenElementsTestTags.TOKEN_ICON),
)
} else {
CurrencyIcon(
state = state.iconState,
modifier = Modifier
.layoutId(layoutId = LayoutId.ICON)
.padding(end = TangemTheme.dimens.spacing8)
.testTag(TokenElementsTestTags.TOKEN_ICON),
)
}
YieldSupplyPromoBanner(
state = state.promoBannerState,
@ -120,14 +141,24 @@ fun TokenItem(
.fillMaxWidth(),
)
TokenTitle(
state = state.titleState,
modifier = Modifier
.layoutId(layoutId = LayoutId.TITLE)
.padding(end = TangemTheme.dimens.spacing8)
.padding(bottom = betweenRowsMargin)
.testTag(TokenElementsTestTags.TOKEN_TITLE),
)
if (composables != null) {
composables.title.invoke(
Modifier
.layoutId(layoutId = LayoutId.TITLE)
.padding(end = TangemTheme.dimens.spacing8)
.padding(bottom = betweenRowsMargin)
.testTag(TokenElementsTestTags.TOKEN_TITLE),
)
} else {
TokenTitle(
state = state.titleState,
modifier = Modifier
.layoutId(layoutId = LayoutId.TITLE)
.padding(end = TangemTheme.dimens.spacing8)
.padding(bottom = betweenRowsMargin)
.testTag(TokenElementsTestTags.TOKEN_TITLE),
)
}
TokenFiatAmount(
state = state.fiatAmountState,

View file

@ -21,7 +21,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.components.token.state.TokenItemState.TitleState as TokenTitleState
@Composable
internal fun TokenTitle(state: TokenTitleState?, modifier: Modifier = Modifier) {
fun TokenTitle(state: TokenTitleState?, modifier: Modifier = Modifier) {
when (state) {
is TokenTitleState.Content -> {
ContentTitle(state = state, modifier = modifier)

View file

@ -1,13 +1,20 @@
package com.tangem.core.ui.components.tokenlist
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@ -18,9 +25,12 @@ import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.account.AccountCharIcon
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.AccountResIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.token.TokenItem
import com.tangem.core.ui.components.token.TokenItemComposables
import com.tangem.core.ui.components.token.internal.TokenTitle
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.internal.GroupTitleItem
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
@ -57,16 +67,80 @@ fun TokenListItem(state: TokensListItemUM, isBalanceHidden: Boolean, modifier: M
}
}
@Suppress("MagicNumber", "ReusedModifierInstance")
@Composable
fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
if (state.isExpanded) {
ExpandedPortfolioHeader(state = state.tokenItemUM, isCollapsable = state.isCollapsable, modifier = modifier)
} else {
TokenItem(
state = state.tokenItemUM,
isBalanceHidden = isBalanceHidden,
modifier = modifier,
)
// Box is not needed here, but due to a bug in compose
// we use it like a wrapper to properly handle lookahead scope inside this composable
Box(modifier) {
SharedTransitionLayout {
AnimatedContent(
state.isExpanded,
transitionSpec = {
fadeIn(animationSpec = tween(350, delayMillis = 90))
.togetherWith(fadeOut(animationSpec = tween(350)))
},
) { isExpanded ->
val animatedContentScope = this
val iconSharedContentState = rememberSharedContentState(key = "icon")
val titleSharedContentState = rememberSharedContentState(key = "title")
val boundsTransform = BoundsTransform { _, _ -> tween(350) }
val composables =
TokenItemComposables(
icon = { modifier: Modifier ->
val iconState = when (val icon = state.tokenItemUM.iconState) {
is CurrencyIconState.CryptoPortfolio.Icon ->
icon.copy(
size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default,
)
is CurrencyIconState.CryptoPortfolio.Letter ->
icon.copy(
size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default,
)
else -> icon
}
CurrencyIcon(
state = iconState,
withFixedSize = false,
modifier = modifier
.sharedBounds(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
),
)
},
title = { modifier: Modifier ->
TokenTitle(
state = state.tokenItemUM.titleState,
modifier = modifier
.sharedElement(
sharedContentState = titleSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
placeholderSize = SharedTransitionScope.PlaceholderSize.AnimatedSize,
),
)
},
)
if (isExpanded) {
ExpandedPortfolioHeader(
state = state.tokenItemUM,
isCollapsable = state.isCollapsable,
composables = composables,
)
} else {
TokenItem(
state = state.tokenItemUM,
isBalanceHidden = isBalanceHidden,
composables = composables,
)
}
}
}
}
}
@ -82,52 +156,89 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B
}
}
@Suppress("LongMethod")
@Composable
fun ExpandedPortfolioHeader(state: TokenItemState, isCollapsable: Boolean, modifier: Modifier = Modifier) {
fun ExpandedPortfolioHeader(
state: TokenItemState,
isCollapsable: Boolean,
composables: TokenItemComposables?,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.clickable(onClick = { state.onItemClick?.invoke(state) })
.padding(
vertical = TangemTheme.dimens.spacing4,
horizontal = TangemTheme.dimens.spacing12,
vertical = if (composables != null) 8.dp else 4.dp,
horizontal = 12.dp,
),
) {
when (val icon = state.iconState) {
is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon(
resId = icon.resId,
color = icon.color,
size = AccountIconSize.ExtraSmall,
)
is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon(
char = icon.char.resolveReference().first(),
color = icon.color,
size = AccountIconSize.ExtraSmall,
)
is CurrencyIconState.CoinIcon,
is CurrencyIconState.CustomTokenIcon,
is CurrencyIconState.Empty,
is CurrencyIconState.FiatIcon,
CurrencyIconState.Loading,
CurrencyIconState.Locked,
is CurrencyIconState.TokenIcon,
-> Unit
if (composables != null) {
composables.icon.invoke(Modifier)
} else {
when (val icon = state.iconState) {
is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon(
resId = icon.resId,
color = icon.color,
size = AccountIconSize.ExtraSmall,
)
is CurrencyIconState.CryptoPortfolio.Letter -> AccountCharIcon(
char = icon.char.resolveReference().first(),
color = icon.color,
size = AccountIconSize.ExtraSmall,
)
is CurrencyIconState.CoinIcon,
is CurrencyIconState.CustomTokenIcon,
is CurrencyIconState.Empty,
is CurrencyIconState.FiatIcon,
CurrencyIconState.Loading,
CurrencyIconState.Locked,
is CurrencyIconState.TokenIcon,
-> Unit
}
}
SpacerW4()
when (val titleState = state.titleState) {
TokenItemState.TitleState.Loading -> Unit
TokenItemState.TitleState.Locked -> Unit
is TokenItemState.TitleState.Content -> Text(
modifier = Modifier.weight(1f),
text = titleState.text.resolveReference(),
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.caption1,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
if (composables != null) {
composables.title.invoke(Modifier.alignByBaseline())
} else {
when (val titleState = state.titleState) {
TokenItemState.TitleState.Loading -> Unit
TokenItemState.TitleState.Locked -> Unit
is TokenItemState.TitleState.Content -> Text(
text = titleState.text.resolveReference(),
color = TangemTheme.colors.text.primary1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.caption1,
)
}
}
val text = when (val fiatAmountState = state.fiatAmountState) {
is TokenItemState.FiatAmountState.Content -> fiatAmountState.text
is TokenItemState.FiatAmountState.TextContent -> fiatAmountState.text
else -> null
}
AnimatedVisibility(text != null) {
Text(
text = text.orEmpty(),
modifier = Modifier
.alignByBaseline()
.padding(horizontal = 8.dp),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.caption1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
if (isCollapsable) {