Updated on 2026-08-14

This commit is contained in:
Tangem 2023-08-29 20:20:21 +08:00
parent 4c8267738b
commit 5d13d36cb3
11 changed files with 582 additions and 540 deletions

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.res.TangemTheme
import com.valentinilk.shimmer.shimmer
@ -14,13 +15,13 @@ import com.valentinilk.shimmer.shimmer
* Rectangle shimmer item with rounded shape from DS
*/
@Composable
fun RectangleShimmer(modifier: Modifier = Modifier) {
fun RectangleShimmer(modifier: Modifier = Modifier, radius: Dp = TangemTheme.dimens.radius6) {
Box(
modifier = modifier
.shimmer()
.background(
color = TangemTheme.colors.button.secondary,
shape = RoundedCornerShape(TangemTheme.dimens.radius6),
shape = RoundedCornerShape(size = radius),
),
)
}
@ -31,16 +32,14 @@ fun RectangleShimmer(modifier: Modifier = Modifier) {
*/
@Composable
fun CircleShimmer(modifier: Modifier = Modifier) {
Box(modifier = modifier.shimmer()) {
Box(
modifier = Modifier
.matchParentSize()
.background(
color = TangemTheme.colors.button.secondary,
shape = CircleShape,
),
)
}
Box(
modifier = modifier
.shimmer()
.background(
color = TangemTheme.colors.button.secondary,
shape = CircleShape,
),
)
}
// region preview

View file

@ -46,6 +46,7 @@ data class TangemDimens internal constructor(
val size10: Dp = 10.dp,
val size11: Dp = 11.dp,
val size12: Dp = 12.dp,
val size14: Dp = 14.dp,
val size16: Dp = 16.dp,
val size18: Dp = 18.dp,
val size20: Dp = 20.dp,

View file

@ -98,7 +98,7 @@ internal object WalletPreviewData {
hasPending = true,
tokenOptions = TokenOptionsState.Visible(
fiatAmount = "321 $",
priceChange = PriceChangeConfig(
config = PriceChangeConfig(
valueInPercent = "2%",
type = PriceChangeConfig.Type.UP,
),
@ -126,7 +126,7 @@ internal object WalletPreviewData {
amount = "5,412 MATIC",
hasPending = true,
tokenOptions = TokenOptionsState.Hidden(
priceChange = PriceChangeConfig(
config = PriceChangeConfig(
valueInPercent = "2%",
type = PriceChangeConfig.Type.UP,
),

View file

@ -1,320 +1,72 @@
package com.tangem.feature.wallet.presentation.common.component
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.Dp
import androidx.constraintlayout.compose.ConstrainedLayoutReference
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.ConstraintLayoutScope
import androidx.constraintlayout.compose.Dimension
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.marketprice.PriceChangeConfig
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemTypography
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.WalletPreviewData
import com.tangem.feature.wallet.presentation.common.component.token.TokenCryptoInfoBlock
import com.tangem.feature.wallet.presentation.common.component.token.TokenFiatInfoBlock
import com.tangem.feature.wallet.presentation.common.component.token.TokenIcon
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.TokenOptionsState
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
private const val DOTS = "•••"
private const val GRAY_SCALE_SATURATION = 0f
val TOKEN_ITEM_HEIGHT: Dp
@Composable
@ReadOnlyComposable
get() = TangemTheme.dimens.size68
// TODO: Add custom token state: [REDACTED_JIRA]
@Composable
internal fun TokenItem(state: TokenItemState, modifier: Modifier = Modifier) {
// TODO: Add custom token state: [REDACTED_JIRA]
when (state) {
is TokenItemState.Content -> ContentTokenItem(state, modifier)
is TokenItemState.Loading -> LoadingTokenItem(modifier)
is TokenItemState.Draggable -> DraggableTokenItem(state, modifier, reorderableTokenListState = null)
is TokenItemState.Unreachable -> UnreachableTokenItem(state, modifier)
is TokenItemState.Locked -> LockedTokenItem(modifier)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ContentTokenItem(content: TokenItemState.Content, modifier: Modifier = Modifier) {
val hapticFeedback = LocalHapticFeedback.current
InternalTokenItem(
modifier = modifier.combinedClickable(
onClick = content.onItemClick,
onLongClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
content.onItemLongClick()
},
),
name = content.name,
tokenIconUrl = content.tokenIconUrl,
tokenIconResId = content.tokenIconResId,
networkBadgeIconResId = content.networkBadgeIconResId,
amount = if (content.tokenOptions is TokenOptionsState.Hidden) DOTS else content.amount,
hasPending = content.hasPending,
options = { ref ->
TokenOptionsBlock(
modifier = Modifier.constrainAsOptionsItem(scope = this, ref),
state = content.tokenOptions,
)
},
isTestnet = content.isTestnet,
)
}
@Composable
internal fun DraggableTokenItem(
state: TokenItemState.Draggable,
internal fun TokenItem(
state: TokenItemState,
modifier: Modifier = Modifier,
reorderableTokenListState: ReorderableLazyListState? = null,
) {
InternalTokenItem(
modifier = modifier,
name = state.name,
tokenIconUrl = state.tokenIconUrl,
tokenIconResId = state.tokenIconResId,
networkBadgeIconResId = state.networkBadgeIconResId,
amount = state.fiatAmount,
hasPending = false,
isTestnet = state.isTestnet,
options = { ref ->
Box(
modifier = Modifier
.size(TangemTheme.dimens.size32)
.constrainAsOptionsItem(scope = this, ref)
.let {
if (reorderableTokenListState != null) {
it.detectReorder(reorderableTokenListState)
} else {
it
}
},
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_drag_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
},
)
}
BaseContainer(modifier = modifier) {
val (iconRef, cryptoInfoRef, fiatInfoRef) = createRefs()
@Composable
internal fun UnreachableTokenItem(state: TokenItemState.Unreachable, modifier: Modifier = Modifier) {
InternalTokenItem(
modifier = modifier,
name = state.name,
tokenIconUrl = state.tokenIconUrl,
tokenIconResId = state.tokenIconResId,
networkBadgeIconResId = state.networkBadgeIconResId,
amount = null,
hasPending = false,
options = { ref ->
Text(
modifier = Modifier.constrainAsOptionsItem(scope = this, ref),
text = stringResource(id = R.string.common_unreachable),
style = TangemTypography.body2,
color = TangemTheme.colors.text.tertiary,
)
},
)
}
TokenIcon(
state = state,
modifier = Modifier.constrainAs(iconRef) {
centerVerticallyTo(parent)
start.linkTo(parent.start)
},
)
@Composable
private fun LoadingTokenItem(modifier: Modifier = Modifier) {
BaseSurface(modifier) {
Row(
TokenCryptoInfoBlock(
state = state,
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing4,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24),
) {
CircleShimmer(modifier = Modifier.size(size = TangemTheme.dimens.size42))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
RectangleShimmer(
modifier = Modifier.size(
width = TangemTheme.dimens.size72,
height = TangemTheme.dimens.size12,
),
)
RectangleShimmer(
modifier = Modifier.size(
width = TangemTheme.dimens.size50,
height = TangemTheme.dimens.size12,
),
)
}
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
RectangleShimmer(
modifier = Modifier.size(
width = TangemTheme.dimens.size40,
height = TangemTheme.dimens.size12,
),
)
RectangleShimmer(
modifier = Modifier.size(
width = TangemTheme.dimens.size40,
height = TangemTheme.dimens.size12,
),
)
}
}
}
.padding(horizontal = TangemTheme.dimens.spacing8)
.constrainAs(cryptoInfoRef) {
centerVerticallyTo(parent)
start.linkTo(iconRef.end)
end.linkTo(fiatInfoRef.start)
width = Dimension.fillToConstraints
},
)
TokenFiatInfoBlock(
state = state,
modifier = Modifier.constrainAsOptionsItem(scope = this, ref = fiatInfoRef),
reorderableTokenListState = reorderableTokenListState,
)
}
}
@Composable
private fun LockedTokenItem(modifier: Modifier = Modifier) {
BaseSurface(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing4,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24),
) {
Box(modifier = Modifier.size(size = TangemTheme.dimens.size42)) {
Box(
modifier = Modifier
.matchParentSize()
.background(
color = TangemTheme.colors.button.secondary,
shape = CircleShape,
),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
LockedContent(
modifier = Modifier.size(
width = TangemTheme.dimens.size72,
height = TangemTheme.dimens.size12,
),
)
LockedContent(
modifier = Modifier.size(
width = TangemTheme.dimens.size50,
height = TangemTheme.dimens.size12,
),
)
}
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
LockedContent(
modifier = Modifier.size(
width = TangemTheme.dimens.size40,
height = TangemTheme.dimens.size12,
),
)
LockedContent(
modifier = Modifier.size(
width = TangemTheme.dimens.size40,
height = TangemTheme.dimens.size12,
),
)
}
}
}
}
}
@Composable
private fun LockedContent(modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = TangemTheme.colors.field.primary,
shape = RoundedCornerShape(TangemTheme.dimens.radius6),
),
)
}
/**
* Block for end part of token item
* shows status is reachable, is drag, hidden or show balance
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun TokenOptionsBlock(state: TokenOptionsState, modifier: Modifier = Modifier) {
AnimatedContent(targetState = state, label = "Update the options", modifier = modifier) { options ->
when (options) {
is TokenOptionsState.Visible -> {
TokenFiatPercentageBlock(fiatAmount = options.fiatAmount, priceChange = options.priceChange)
}
is TokenOptionsState.Hidden -> {
TokenFiatPercentageBlock(fiatAmount = DOTS, priceChange = options.priceChange)
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun InternalTokenItem(
name: String,
tokenIconUrl: String?,
@DrawableRes tokenIconResId: Int,
@DrawableRes networkBadgeIconResId: Int?,
amount: String?,
hasPending: Boolean,
options: @Composable ConstraintLayoutScope.(ref: ConstrainedLayoutReference) -> Unit,
inline fun BaseContainer(
modifier: Modifier = Modifier,
isTestnet: Boolean = false,
crossinline content: @Composable ConstraintLayoutScope.() -> Unit,
) {
Box(
modifier = modifier
.defaultMinSize(minHeight = TOKEN_ITEM_HEIGHT)
.defaultMinSize(minHeight = TangemTheme.dimens.size68)
.background(color = TangemTheme.colors.background.primary),
) {
ConstraintLayout(
@ -322,38 +74,10 @@ private fun InternalTokenItem(
.fillMaxSize()
.padding(
horizontal = TangemTheme.dimens.spacing14,
vertical = TangemTheme.dimens.spacing4,
vertical = TangemTheme.dimens.spacing14,
),
) {
val (iconItem, tokenNameItem, optionsItem) = createRefs()
TokenIcon(
modifier = Modifier.constrainAs(iconItem) {
centerVerticallyTo(parent)
start.linkTo(parent.start)
},
tokenIconUrl = tokenIconUrl,
tokenIconResId = tokenIconResId,
networkBadgeIconRes = networkBadgeIconResId,
isTestnet = isTestnet,
)
TokenTitleAmountBlock(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing8)
.constrainAs(tokenNameItem) {
centerVerticallyTo(parent)
start.linkTo(iconItem.end)
end.linkTo(optionsItem.start)
width = Dimension.fillToConstraints
},
title = name,
amount = amount,
hasPending = hasPending,
)
options(optionsItem)
}
content = content,
)
}
}
@ -367,173 +91,6 @@ private fun Modifier.constrainAsOptionsItem(scope: ConstraintLayoutScope, ref: C
}
}
@Composable
private fun BaseSurface(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.defaultMinSize(minHeight = TOKEN_ITEM_HEIGHT),
color = TangemTheme.colors.background.primary,
onClick = onClick ?: {},
enabled = onClick != null,
) {
content()
}
}
@Composable
private fun TokenTitleAmountBlock(title: String, amount: String?, hasPending: Boolean, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
Text(
text = title,
style = TangemTypography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
AnimatedVisibility(visible = hasPending, modifier = Modifier.align(Alignment.CenterVertically)) {
Image(
painter = painterResource(id = R.drawable.img_loader_15),
contentDescription = null,
)
}
}
AnimatedVisibility(visible = !amount.isNullOrBlank()) {
Text(
text = requireNotNull(amount),
style = TangemTypography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun TokenFiatPercentageBlock(
fiatAmount: String,
priceChange: PriceChangeConfig,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.requiredWidth(IntrinsicSize.Max)) {
Text(
modifier = Modifier.align(Alignment.End),
text = fiatAmount,
style = TangemTypography.body2,
color = TangemTheme.colors.text.primary1,
)
SpacerH2()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
AnimatedContent(
targetState = priceChange.type,
label = "Update the price change's arrow",
modifier = Modifier.align(Alignment.CenterVertically),
) {
Image(
painter = painterResource(
id = when (priceChange.type) {
PriceChangeConfig.Type.UP -> R.drawable.img_arrow_up_8
PriceChangeConfig.Type.DOWN -> R.drawable.img_arrow_down_8
},
),
contentDescription = null,
)
}
SpacerW4()
AnimatedContent(
targetState = priceChange.type,
label = "Update the price change's arrow",
modifier = Modifier.align(Alignment.CenterVertically),
) {
Text(
modifier = Modifier.align(Alignment.CenterVertically),
text = priceChange.valueInPercent,
style = TangemTypography.body2,
color = when (priceChange.type) {
PriceChangeConfig.Type.UP -> TangemTheme.colors.text.accent
PriceChangeConfig.Type.DOWN -> TangemTheme.colors.text.warning
},
)
}
}
}
}
@Composable
private fun TokenIcon(
tokenIconUrl: String?,
@DrawableRes tokenIconResId: Int,
isTestnet: Boolean,
modifier: Modifier = Modifier,
@DrawableRes networkBadgeIconRes: Int? = null,
) {
Box(
modifier = modifier
.padding(end = TangemTheme.dimens.spacing16)
.size(TangemTheme.dimens.size42),
) {
val tokenImageModifier = Modifier
.align(Alignment.BottomStart)
.size(TangemTheme.dimens.size36)
val iconData: Any = remember(tokenIconUrl) {
if (tokenIconUrl.isNullOrEmpty()) tokenIconResId else tokenIconUrl
}
val colorFilter = remember(isTestnet) {
if (isTestnet) {
val colorMatrix = ColorMatrix().also {
it.setToSaturation(GRAY_SCALE_SATURATION)
}
ColorFilter.colorMatrix(colorMatrix)
} else {
null
}
}
SubcomposeAsyncImage(
modifier = tokenImageModifier,
model = ImageRequest.Builder(LocalContext.current)
.data(iconData)
.placeholder(tokenIconResId)
.error(tokenIconResId)
.fallback(tokenIconResId)
.crossfade(enable = true)
.build(),
colorFilter = colorFilter,
contentDescription = null,
)
AnimatedVisibility(
visible = networkBadgeIconRes != null,
modifier = Modifier
.align(Alignment.TopEnd)
.size(TangemTheme.dimens.size18)
.background(color = Color.White, shape = CircleShape),
) {
Image(
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing0_5)
.align(Alignment.Center),
painter = painterResource(id = requireNotNull(networkBadgeIconRes)),
colorFilter = colorFilter,
contentDescription = null,
)
}
}
}
// region preview
@Preview

View file

@ -0,0 +1,18 @@
package com.tangem.feature.wallet.presentation.common.component.token
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.TangemTheme
@Composable
internal fun LockedRectangle(modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = TangemTheme.colors.background.secondary,
shape = RoundedCornerShape(TangemTheme.dimens.radius4),
),
)
}

View file

@ -0,0 +1,116 @@
package com.tangem.feature.wallet.presentation.common.component.token
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
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.composed
import androidx.compose.ui.res.painterResource
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemTypography
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.Companion.DOTS
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.TokenOptionsState
@Composable
internal fun TokenCryptoInfoBlock(state: TokenItemState, modifier: Modifier = Modifier) {
when (state) {
is TokenItemState.ContentState -> ContentBlock(state = state, modifier = modifier)
is TokenItemState.Loading -> LoadingBlock(modifier = modifier)
is TokenItemState.Locked -> LockedBlock(modifier = modifier)
}
}
@Composable
private fun ContentBlock(state: TokenItemState.ContentState, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
CurrencyNameText(
name = state.name,
hasPending = (state as? TokenItemState.Content)?.hasPending == true,
)
AmountText(
amount = when (state) {
is TokenItemState.Content -> if (state.tokenOptions is TokenOptionsState.Hidden) DOTS else state.amount
is TokenItemState.Draggable -> state.fiatAmount
is TokenItemState.Unreachable -> null
},
)
}
}
@Composable
private fun CurrencyNameText(name: String, hasPending: Boolean) {
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
Text(
text = name,
style = TangemTypography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
PendingTransactionImage(hasPending = hasPending, modifier = Modifier.align(Alignment.CenterVertically))
}
}
@Composable
private fun PendingTransactionImage(hasPending: Boolean, modifier: Modifier = Modifier) {
AnimatedVisibility(visible = hasPending, modifier = modifier) {
Image(
painter = painterResource(id = R.drawable.img_loader_15),
contentDescription = null,
)
}
}
@Composable
private fun AmountText(amount: String?) {
AnimatedVisibility(visible = !amount.isNullOrBlank()) {
if (amount == null) return@AnimatedVisibility
Text(
text = amount,
style = TangemTypography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
}
@Composable
private fun LoadingBlock(modifier: Modifier = Modifier) {
NonContentContainer(modifier = modifier) {
RectangleShimmer(modifier = Modifier.nameSize(), radius = TangemTheme.dimens.radius4)
RectangleShimmer(modifier = Modifier.amountSize(), radius = TangemTheme.dimens.radius4)
}
}
@Composable
private fun LockedBlock(modifier: Modifier = Modifier) {
NonContentContainer(modifier = modifier) {
LockedRectangle(modifier = Modifier.nameSize())
LockedRectangle(modifier = Modifier.amountSize())
}
}
@Composable
private fun NonContentContainer(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10),
content = content,
)
}
private fun Modifier.nameSize(): Modifier = composed {
return@composed size(width = TangemTheme.dimens.size72, height = TangemTheme.dimens.size12)
}
private fun Modifier.amountSize(): Modifier = composed {
return@composed size(width = TangemTheme.dimens.size50, height = TangemTheme.dimens.size12)
}

View file

@ -0,0 +1,180 @@
package com.tangem.feature.wallet.presentation.common.component.token
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW4
import com.tangem.core.ui.components.marketprice.PriceChangeConfig
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemTypography
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.Companion.DOTS
import com.tangem.feature.wallet.presentation.common.state.TokenItemState.TokenOptionsState
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
@Composable
internal fun TokenFiatInfoBlock(
state: TokenItemState,
modifier: Modifier = Modifier,
reorderableTokenListState: ReorderableLazyListState? = null,
) {
when (state) {
is TokenItemState.Content -> ContentBlock(state = state.tokenOptions, modifier = modifier)
is TokenItemState.Draggable -> {
DraggableBlock(
modifier = modifier,
reorderableTokenListState = reorderableTokenListState,
)
}
is TokenItemState.Unreachable -> UnreachableBlock(modifier = modifier)
is TokenItemState.Loading -> LoadingBlock(modifier = modifier)
is TokenItemState.Locked -> LockedBlock(modifier = modifier)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun ContentBlock(state: TokenOptionsState, modifier: Modifier = Modifier) {
Column(
modifier = modifier.requiredWidth(IntrinsicSize.Max),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2),
) {
AnimatedContent(
targetState = state,
label = "Update the fiat percentage block",
modifier = Modifier.align(Alignment.End),
) {
Text(
text = when (it) {
is TokenOptionsState.Visible -> it.fiatAmount
is TokenOptionsState.Hidden -> DOTS
},
style = TangemTypography.body2,
color = TangemTheme.colors.text.primary1,
)
}
PriceChangeBlock(config = state.config)
}
}
@Composable
private fun PriceChangeBlock(config: PriceChangeConfig) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
PriceChangeIcon(type = config.type, modifier = Modifier.align(Alignment.CenterVertically))
SpacerW4()
PriceChangeText(config = config, modifier = Modifier.align(Alignment.CenterVertically))
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun PriceChangeIcon(type: PriceChangeConfig.Type, modifier: Modifier = Modifier) {
AnimatedContent(
targetState = type,
label = "Update the price change's arrow",
modifier = modifier,
) {
Image(
painter = painterResource(
id = when (it) {
PriceChangeConfig.Type.UP -> R.drawable.img_arrow_up_8
PriceChangeConfig.Type.DOWN -> R.drawable.img_arrow_down_8
},
),
contentDescription = null,
)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun PriceChangeText(config: PriceChangeConfig, modifier: Modifier = Modifier) {
AnimatedContent(
targetState = config.type,
label = "Update the price change's arrow",
modifier = modifier,
) {
Text(
text = config.valueInPercent,
style = TangemTypography.body2,
color = when (it) {
PriceChangeConfig.Type.UP -> TangemTheme.colors.text.accent
PriceChangeConfig.Type.DOWN -> TangemTheme.colors.text.warning
},
)
}
}
@Composable
private fun DraggableBlock(reorderableTokenListState: ReorderableLazyListState?, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens.size32)
.then(
other = if (reorderableTokenListState != null) {
Modifier.detectReorder(reorderableTokenListState)
} else {
Modifier
},
),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = R.drawable.ic_drag_24),
tint = TangemTheme.colors.icon.informative,
contentDescription = null,
)
}
}
@Composable
private fun UnreachableBlock(modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = stringResource(id = R.string.common_unreachable),
style = TangemTypography.body2,
color = TangemTheme.colors.text.tertiary,
)
}
@Composable
private fun LoadingBlock(modifier: Modifier = Modifier) {
NonContentContainer(modifier = modifier) {
RectangleShimmer(modifier = Modifier.viewSize(), radius = TangemTheme.dimens.radius4)
RectangleShimmer(modifier = Modifier.viewSize(), radius = TangemTheme.dimens.radius4)
}
}
@Composable
private fun LockedBlock(modifier: Modifier = Modifier) {
NonContentContainer(modifier = modifier) {
LockedRectangle(modifier = Modifier.viewSize())
LockedRectangle(modifier = Modifier.viewSize())
}
}
@Composable
private fun NonContentContainer(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing10),
content = content,
)
}
private fun Modifier.viewSize(): Modifier = composed {
return@composed size(width = TangemTheme.dimens.size40, height = TangemTheme.dimens.size12)
}

View file

@ -0,0 +1,152 @@
package com.tangem.feature.wallet.presentation.common.component.token
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.presentation.common.state.TokenItemState
private const val GRAY_SCALE_SATURATION = 0f
@Composable
internal fun TokenIcon(state: TokenItemState, modifier: Modifier = Modifier) {
when (state) {
is TokenItemState.ContentState -> ContentIcon(content = state, modifier = modifier)
is TokenItemState.Loading -> LoadingIcon(modifier = modifier)
is TokenItemState.Locked -> LockedIcon(modifier = modifier)
}
}
@Composable
private fun ContentIcon(content: TokenItemState.ContentState, modifier: Modifier = Modifier) {
BaseContainer(modifier = modifier) {
val isTestnet = when (content) {
is TokenItemState.Content -> content.isTestnet
is TokenItemState.Draggable -> content.isTestnet
is TokenItemState.Unreachable -> false
}
val colorFilter = remember(isTestnet) {
if (isTestnet) {
ColorFilter.colorMatrix(
colorMatrix = ColorMatrix().apply { setToSaturation(GRAY_SCALE_SATURATION) },
)
} else {
null
}
}
Icon(
content = content,
colorFilter = colorFilter,
modifier = Modifier.align(Alignment.BottomStart),
)
NetworkBadge(
iconResId = content.networkBadgeIconResId,
colorFilter = colorFilter,
modifier = Modifier.align(Alignment.TopEnd),
)
}
}
@Composable
private fun Icon(content: TokenItemState.ContentState, colorFilter: ColorFilter?, modifier: Modifier = Modifier) {
val iconUrl = content.tokenIconUrl
val iconData: Any = remember(iconUrl) {
if (iconUrl.isNullOrEmpty()) content.tokenIconResId else iconUrl
}
SubcomposeAsyncImage(
modifier = modifier.iconSize(),
model = ImageRequest.Builder(context = LocalContext.current)
.data(data = iconData)
.placeholder(drawableResId = content.tokenIconResId)
.error(drawableResId = content.tokenIconResId)
.fallback(drawableResId = content.tokenIconResId)
.crossfade(enable = true)
.build(),
colorFilter = colorFilter,
contentDescription = null,
)
}
@Composable
private fun BoxScope.NetworkBadge(
@DrawableRes iconResId: Int?,
colorFilter: ColorFilter?,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = iconResId != null,
modifier = modifier
.size(TangemTheme.dimens.size18)
.background(color = TangemTheme.colors.background.primary, shape = CircleShape),
) {
if (iconResId == null) return@AnimatedVisibility
Image(
modifier = Modifier
.padding(all = TangemTheme.dimens.spacing2)
.align(Alignment.Center),
painter = painterResource(id = iconResId),
colorFilter = colorFilter,
contentDescription = null,
)
}
}
@Composable
private fun LoadingIcon(modifier: Modifier = Modifier) {
BaseContainer(modifier) {
CircleShimmer(
modifier = Modifier
.iconSize()
.align(alignment = Alignment.BottomStart),
)
}
}
@Composable
private fun LockedIcon(modifier: Modifier = Modifier) {
BaseContainer(modifier) {
Box(
modifier = Modifier
.iconSize()
.align(Alignment.BottomStart),
) {
Box(
modifier = Modifier
.matchParentSize()
.background(color = TangemTheme.colors.background.secondary, shape = CircleShape),
)
}
}
}
@Composable
private inline fun BaseContainer(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Box(modifier = modifier.size(size = TangemTheme.dimens.size40), content = content)
}
private fun Modifier.iconSize(): Modifier = composed {
return@composed this.size(size = TangemTheme.dimens.size36)
}

View file

@ -14,92 +14,111 @@ internal sealed interface TokenItemState {
/** Loading token state */
data class Loading(override val id: String) : TokenItemState
/** Locked token state */
data class Locked(override val id: String) : TokenItemState
/** Content state */
sealed class ContentState(
override val id: String,
open val tokenIconUrl: String?,
@DrawableRes open val tokenIconResId: Int,
@DrawableRes open val networkBadgeIconResId: Int?,
open val name: String,
) : TokenItemState
/**
* Content token state
*
* @property id unique id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property name token name
* @property amount amount of token
* @property hasPending pending tx in blockchain
* @property tokenOptions state for token options
* @property isTestnet indicates whether the token is from test network or not
* @property onItemClick callback which will be called when an item is clicked
* @property onItemLongClick callback which will be called when an item is long clicked
* @property id unique id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property name token name
* @property amount amount of token
* @property hasPending pending tx in blockchain
* @property tokenOptions state for token options
* @property isTestnet indicates whether the token is from test network or not
* @property onItemClick callback which will be called when an item is clicked
* @property onItemLongClick callback which will be called when an item is long clicked
*/
data class Content(
override val id: String,
val tokenIconUrl: String?,
@DrawableRes val tokenIconResId: Int,
@DrawableRes val networkBadgeIconResId: Int?,
val name: String,
override val tokenIconUrl: String?,
@DrawableRes override val tokenIconResId: Int,
@DrawableRes override val networkBadgeIconResId: Int?,
override val name: String,
val amount: String,
val hasPending: Boolean,
val tokenOptions: TokenOptionsState,
val isTestnet: Boolean,
val onItemClick: () -> Unit,
val onItemLongClick: () -> Unit,
) : TokenItemState
) : ContentState(id, tokenIconUrl, tokenIconResId, networkBadgeIconResId, name)
/**
* Draggable token state
*
* @property id unique id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property name token name
* @property fiatAmount fiat amount of token
* @property isTestnet indicates whether the token is from test network or not
* @property id unique id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property name token name
* @property fiatAmount fiat amount of token
* @property isTestnet indicates whether the token is from test network or not
*/
data class Draggable(
override val id: String,
val tokenIconUrl: String?,
@DrawableRes val tokenIconResId: Int,
@DrawableRes val networkBadgeIconResId: Int?,
val name: String,
override val tokenIconUrl: String?,
@DrawableRes override val tokenIconResId: Int,
@DrawableRes override val networkBadgeIconResId: Int?,
override val name: String,
val fiatAmount: String,
val isTestnet: Boolean,
) : TokenItemState
) : ContentState(id, tokenIconUrl, tokenIconResId, networkBadgeIconResId, name)
/**
* Unreachable token state
*
* @property id token id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property name token name
* @property id token id
* @property tokenIconUrl token icon url
* @property tokenIconResId token icon resource id
* @property networkBadgeIconResId network badge icon resource id, may be null if it is a coin
* @property name token name
*/
data class Unreachable(
override val id: String,
val tokenIconUrl: String?,
@DrawableRes val tokenIconResId: Int,
@DrawableRes val networkBadgeIconResId: Int?,
val name: String,
) : TokenItemState
override val tokenIconUrl: String?,
@DrawableRes override val tokenIconResId: Int,
@DrawableRes override val networkBadgeIconResId: Int?,
override val name: String,
) : ContentState(id, tokenIconUrl, tokenIconResId, networkBadgeIconResId, name)
/** Token options state */
@Immutable
sealed interface TokenOptionsState {
val config: PriceChangeConfig
/**
* Visible token options state
*
* @property fiatAmount fiat amount of token
* @property priceChange value of price changing
* @property fiatAmount fiat amount of token
* @property config value of price changing
*/
data class Visible(val fiatAmount: String, val priceChange: PriceChangeConfig) : TokenOptionsState
data class Visible(
override val config: PriceChangeConfig,
val fiatAmount: String,
) : TokenOptionsState
/**
* Hidden token options state
*
* @property priceChange value of price changing
* @property config value of price changing
*/
data class Hidden(val priceChange: PriceChangeConfig) : TokenOptionsState
data class Hidden(override val config: PriceChangeConfig) : TokenOptionsState
}
companion object {
const val DOTS = "•••"
}
}

View file

@ -33,7 +33,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.WalletPreviewData
import com.tangem.feature.wallet.presentation.common.component.DraggableNetworkGroupItem
import com.tangem.feature.wallet.presentation.common.component.DraggableTokenItem
import com.tangem.feature.wallet.presentation.common.component.TokenItem
import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState
import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensState
@ -151,7 +151,7 @@ private fun LazyItemScope.DraggableItem(
networkName = item.networkName,
reorderableTokenListState = reorderableState,
)
is DraggableItem.Token -> DraggableTokenItem(
is DraggableItem.Token -> TokenItem(
modifier = itemModifier,
state = item.tokenItemState,
reorderableTokenListState = reorderableState,

View file

@ -47,7 +47,7 @@ internal class CryptoCurrencyStatusToTokenItemConverter(
} else {
TokenItemState.TokenOptionsState.Visible(
fiatAmount = getFormattedFiatAmount(),
priceChange = getPriceChangeConfig(),
config = getPriceChangeConfig(),
)
},
isTestnet = currency.network.isTestnet,