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) {

View file

@ -218,6 +218,7 @@ private fun LazyItemScope.DraggableItem(
is DraggableItem.Portfolio -> ExpandedPortfolioHeader(
state = item.tokenItemState,
isCollapsable = false,
composables = null,
modifier = modifierWithBackground
.padding(top = 8.dp)
.fillMaxWidth(),

View file

@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
@ -57,7 +56,6 @@ import com.tangem.core.ui.components.rememberIsKeyboardVisible
import com.tangem.core.ui.components.sheetscaffold.*
import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar
import com.tangem.core.ui.components.snackbar.TangemSnackbar
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.stringResourceSafe
@ -79,7 +77,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.holder.TxHistor
import com.tangem.feature.wallet.presentation.wallet.ui.components.TokenActionsBottomSheet
import com.tangem.feature.wallet.presentation.wallet.ui.components.WalletsList
import com.tangem.feature.wallet.presentation.wallet.ui.components.common.*
import com.tangem.feature.wallet.presentation.wallet.ui.components.fastForEach
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.nftCollections
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton
import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock
@ -154,7 +151,6 @@ private fun WalletContent(
*/
val selectedWalletIndex by remember(state.selectedWalletIndex) { mutableIntStateOf(state.selectedWalletIndex) }
val selectedWallet = state.wallets.getOrElse(selectedWalletIndex) { state.wallets[state.selectedWalletIndex] }
val (expandedState, collapsedState) = getExpandPortfolioStates(selectedWallet)
val listState = rememberLazyListState()
@ -199,6 +195,7 @@ private fun WalletContent(
contentType = state.wallets.map { it.walletCardState.id },
) {
WalletsList(
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
lazyListState = walletsListState,
wallets = state.wallets.map(WalletState::walletCardState).toImmutableList(),
isBalanceHidden = state.isHidingMode,
@ -263,13 +260,6 @@ private fun WalletContent(
txHistoryItems = txHistoryItems,
isBalanceHidden = state.isHidingMode,
modifier = movableItemModifier,
portfolioVisibleState = {
findPortfolioVisibleState(
portfolio = it,
expandedState = expandedState,
collapsedState = collapsedState,
)
},
)
nftCollections(state = selectedWallet, itemModifier = itemModifier)
@ -307,61 +297,6 @@ private fun WalletContent(
)
}
private fun findPortfolioVisibleState(
portfolio: TokensListItemUM.Portfolio,
expandedState: SnapshotStateMap<String, MutableTransitionState<Boolean>>,
collapsedState: SnapshotStateMap<String, MutableTransitionState<Boolean>>,
): MutableTransitionState<Boolean> {
val portfolioKey = portfolio.id
fun forceVisible() = MutableTransitionState(true).apply { targetState = true }
val shouldExpand = portfolio.isExpanded
return if (shouldExpand) {
collapsedState[portfolioKey] ?: forceVisible()
} else {
expandedState[portfolioKey] ?: forceVisible()
}
}
@Composable
private fun getExpandPortfolioStates(
state: WalletState,
): Pair<
SnapshotStateMap<String, MutableTransitionState<Boolean>>,
SnapshotStateMap<String, MutableTransitionState<Boolean>>,
> {
val expandedTransitionState =
remember { mutableStateMapOf<String, MutableTransitionState<Boolean>>() }
val collapsedTransitionState =
remember { mutableStateMapOf<String, MutableTransitionState<Boolean>>() }
val portfolioContent = state is WalletState.MultiCurrency.Content &&
state.tokensListState is WalletTokensListState.ContentState.PortfolioContent
if (!portfolioContent) {
return expandedTransitionState to collapsedTransitionState
}
state.tokensListState.items.fastForEach { portfolio ->
val portfolioKey = portfolio.id
val shouldExpand = portfolio.isExpanded
fun toggleVisible() = MutableTransitionState(false).apply { targetState = true }
fun forceVisible() = MutableTransitionState(true).apply { targetState = true }
val isFirstCall = collapsedTransitionState[portfolioKey] == null ||
expandedTransitionState[portfolioKey] == null
when {
isFirstCall -> {
collapsedTransitionState[portfolioKey] = forceVisible()
expandedTransitionState[portfolioKey] = forceVisible()
}
shouldExpand -> expandedTransitionState[portfolioKey] = toggleVisible()
else -> collapsedTransitionState[portfolioKey] = toggleVisible()
}
}
return expandedTransitionState to collapsedTransitionState
}
@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View file

@ -47,13 +47,14 @@ internal fun WalletsList(
lazyListState: LazyListState,
wallets: ImmutableList<WalletCardState>,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
val horizontalCardPadding = TangemTheme.dimens.spacing16
val screenWidth = LocalWindowSize.current.width
val itemWidth by remember(screenWidth) { derivedStateOf { screenWidth - horizontalCardPadding * 2 } }
LazyRow(
modifier = Modifier.background(color = TangemTheme.colors.background.secondary),
modifier = modifier.background(color = TangemTheme.colors.background.secondary),
state = lazyListState,
contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing16),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8),

View file

@ -1,10 +1,8 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.common
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import androidx.paging.compose.LazyPagingItems
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
@ -24,11 +22,10 @@ internal fun LazyListScope.contentItems(
txHistoryItems: LazyPagingItems<TxHistoryState.TxHistoryItemState>?,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
portfolioVisibleState: (portfolio: TokensListItemUM.Portfolio) -> MutableTransitionState<Boolean>,
) {
when (state) {
is WalletState.MultiCurrency -> {
tokensListItems(state.tokensListState, modifier, isBalanceHidden, portfolioVisibleState)
tokensListItems(state.tokensListState, modifier, isBalanceHidden)
}
is WalletState.SingleCurrency -> {
txHistoryItems(state.txHistoryState, txHistoryItems, isBalanceHidden, modifier)

View file

@ -1,13 +1,17 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import androidx.compose.animation.*
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
@ -20,12 +24,12 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
internal fun LazyListScope.portfolioContentItems(
items: ImmutableList<TokensListItemUM.Portfolio>,
modifier: Modifier = Modifier,
isBalanceHidden: Boolean,
portfolioVisibleState: (portfolio: TokensListItemUM.Portfolio) -> MutableTransitionState<Boolean>,
) {
items.forEachIndexed { index, item ->
portfolioTokensList(
@ -33,7 +37,6 @@ internal fun LazyListScope.portfolioContentItems(
modifier = modifier,
portfolioIndex = index,
isBalanceHidden = isBalanceHidden,
portfolioVisibleState = portfolioVisibleState,
)
}
}
@ -43,35 +46,34 @@ internal fun LazyListScope.portfolioTokensList(
modifier: Modifier,
portfolioIndex: Int,
isBalanceHidden: Boolean,
portfolioVisibleState: (portfolio: TokensListItemUM.Portfolio) -> MutableTransitionState<Boolean>,
) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
val lastIndex = tokens.lastIndex.inc()
portfolioItem(
portfolio = portfolio,
modifier = modifier,
portfolioIndex = portfolioIndex,
isBalanceHidden = isBalanceHidden,
portfolioVisibleState = portfolioVisibleState,
)
if (!isExpanded) return
if (tokens.isEmpty()) {
item(
key = "$NON_CONTENT_TOKENS_LIST_KEY account-${portfolio.id}",
contentType = "$NON_CONTENT_TOKENS_LIST_KEY account-${portfolio.id}",
) {
val appear = portfolioVisibleState(portfolio)
SlideInItemVisibility(
currentIndex = 1,
lastIndex = lastIndex,
modifier = modifier
.animateItem()
.animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null)
.roundedShapeItemDecoration(
radius = TangemTheme.dimens.radius14,
currentIndex = 1,
lastIndex = 1,
backgroundColor = TangemTheme.colors.background.primary,
),
visibleState = appear,
visible = isExpanded,
) {
NonContentItemContent(
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing28),
@ -86,19 +88,19 @@ internal fun LazyListScope.portfolioTokensList(
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, token ->
val indexWithHeader = tokenIndex.inc()
val lastIndex = tokens.lastIndex.inc()
val appear = portfolioVisibleState(portfolio)
SlideInItemVisibility(
currentIndex = tokenIndex,
lastIndex = lastIndex,
modifier = modifier
.testModifier(indexWithHeader)
.animateItem()
.animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null)
.roundedShapeItemDecoration(
radius = TangemTheme.dimens.radius14,
currentIndex = indexWithHeader,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
),
visibleState = appear,
visible = isExpanded,
) {
val modifier = if (indexWithHeader == lastIndex) Modifier.padding(bottom = 8.dp) else Modifier
PortfolioTokensListItem(
@ -116,11 +118,9 @@ private fun LazyListScope.portfolioItem(
modifier: Modifier,
portfolioIndex: Int,
isBalanceHidden: Boolean,
portfolioVisibleState: (portfolio: TokensListItemUM.Portfolio) -> MutableTransitionState<Boolean>,
) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
val lastIndex = when {
isExpanded && tokens.isEmpty() -> 1
isExpanded -> tokens.lastIndex.inc()
@ -128,60 +128,61 @@ private fun LazyListScope.portfolioItem(
}
item(
key = "account-${portfolio.id}-isExpanded$isExpanded",
contentType = "account-isExpanded$isExpanded",
key = "account-${portfolio.id}",
contentType = "account-content",
) {
val anchorModifier = modifier
.testModifier(portfolioIndex)
.animateItem()
.roundedShapeItemDecoration(
currentIndex = 0,
radius = TangemTheme.dimens.radius14,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
val appear = portfolioVisibleState(portfolio)
if (isExpanded) {
SlideInItemVisibility(
modifier = anchorModifier,
visibleState = appear,
) {
PortfolioListItem(
state = portfolio,
isBalanceHidden = isBalanceHidden,
modifier = Modifier.padding(vertical = 8.dp),
)
}
} else {
AnimatedVisibility(
modifier = anchorModifier,
visibleState = appear,
enter = fadeIn(),
exit = ExitTransition.None,
) {
PortfolioListItem(
state = portfolio,
isBalanceHidden = isBalanceHidden,
)
var lastIndexProxy by remember { mutableIntStateOf(lastIndex) }
// When collapsing the portfolio, we delay updating lastIndexProxy to allow
// shrinking animation to complete before changing the shape.
LaunchedEffect(lastIndex) {
if (lastIndex != 0) {
lastIndexProxy = lastIndex
return@LaunchedEffect
}
delay(timeMillis = (50 * tokens.size).toLong())
lastIndexProxy = 0
}
PortfolioListItem(
state = portfolio,
isBalanceHidden = isBalanceHidden,
modifier = modifier
.testModifier(portfolioIndex)
.roundedShapeItemDecoration(
currentIndex = 0,
radius = TangemTheme.dimens.radius14,
lastIndex = lastIndexProxy,
backgroundColor = TangemTheme.colors.background.primary,
),
)
}
}
@Suppress("MagicNumber")
@Composable
private fun SlideInItemVisibility(
visibleState: MutableTransitionState<Boolean>,
visible: Boolean,
currentIndex: Int,
lastIndex: Int,
modifier: Modifier = Modifier,
content: @Composable() AnimatedVisibilityScope.() -> Unit,
content: @Composable () -> Unit,
) {
AnimatedVisibility(
modifier = modifier,
visibleState = visibleState,
enter = slideInVertically(
animationSpec = tween(easing = LinearOutSlowInEasing),
initialOffsetY = { it },
) + fadeIn(),
exit = ExitTransition.None,
visible = visible,
enter = fadeIn(
tween(100, delayMillis = 50 * currentIndex),
) + expandVertically(
tween(100, delayMillis = 50 * currentIndex),
expandFrom = Alignment.Top,
),
exit = fadeOut(
tween(100, delayMillis = 50 * (lastIndex - currentIndex - 1)),
) + shrinkVertically(
tween(100, delayMillis = 50 * (lastIndex - currentIndex - 1)),
shrinkTowards = Alignment.Top,
),
) {
content()
}

View file

@ -1,6 +1,5 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@ -41,14 +40,12 @@ internal fun LazyListScope.tokensListItems(
state: WalletTokensListState,
modifier: Modifier = Modifier,
isBalanceHidden: Boolean,
portfolioVisibleState: (portfolio: TokensListItemUM.Portfolio) -> MutableTransitionState<Boolean>,
) {
when (state) {
is WalletTokensListState.ContentState.PortfolioContent -> portfolioContentItems(
items = state.items,
isBalanceHidden = isBalanceHidden,
modifier = modifier,
portfolioVisibleState = portfolioVisibleState,
)
is WalletTokensListState.ContentState.Content,
is WalletTokensListState.ContentState.Loading,