From 59080b682de05b83953ebb565fc00c1e2bc6edb5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 6 Oct 2023 15:00:57 +0800 Subject: [PATCH] Updated on 2026-08-14 --- .../common/component/TokenItem.kt | 463 ++++++++++++------ .../common/component/token/TokenTitle.kt | 1 + .../common/state/TokenItemState.kt | 2 +- 3 files changed, 313 insertions(+), 153 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt index 010ee08900..0d23420655 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/component/TokenItem.kt @@ -3,19 +3,16 @@ package com.tangem.feature.wallet.presentation.common.component import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.composed -import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.constraintlayout.compose.* +import androidx.compose.ui.unit.Constraints import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.extensions.rememberHapticFeedback import com.tangem.core.ui.res.TangemTheme @@ -24,152 +21,64 @@ import com.tangem.feature.wallet.presentation.common.component.token.* import com.tangem.feature.wallet.presentation.common.component.token.icon.TokenIcon import com.tangem.feature.wallet.presentation.common.state.TokenItemState import org.burnoutcrew.reorderable.ReorderableLazyListState +import kotlin.math.max + +private const val TITLE_MIN_WIDTH_COEFFICIENT = 0.22 +private const val PRICE_CHANGE_MIN_WIDTH_COEFFICIENT = 0.16 + +private enum class LayoutId { + ICON, TITLE, FIAT_AMOUNT, CRYPTO_AMOUNT, PRICE_CHANGE, NON_FIAT_CONTENT +} -@Suppress("LongMethod") @Composable internal fun TokenItem( state: TokenItemState, modifier: Modifier = Modifier, reorderableTokenListState: ReorderableLazyListState? = null, ) { - var rootWidth by remember { mutableStateOf(Int.MIN_VALUE) } - - @Suppress("DestructuringDeclarationWithTooManyEntries") - BaseContainer( + CustomContainer( + state = state, modifier = modifier - .tokenClickable(state) - .onSizeChanged { rootWidth = it.width }, + .tokenClickable(state = state) + .background(color = TangemTheme.colors.background.primary), ) { - val (iconRef, titleRef, cryptoAmountRef, fiatAmountRef, priceChangeRef, nonFiatContentRef) = createRefs() - val isBalanceHidden = (state as? TokenItemState.Content)?.isBalanceHidden ?: false - TokenIcon( - state = state.iconState, - modifier = Modifier.constrainAs(iconRef) { - centerVerticallyTo(parent) - start.linkTo(parent.start) - }, - ) - - val density = LocalDensity.current - val titleRequiredMinWidth by remember(rootWidth) { - derivedStateOf { with(density) { rootWidth.toDp().times(other = 0.22f) } } - } + TokenIcon(state = state.iconState, modifier = Modifier.layoutId(layoutId = LayoutId.ICON)) TokenTitle( state = state.titleState, modifier = Modifier + .layoutId(layoutId = LayoutId.TITLE) .padding(horizontal = TangemTheme.dimens.spacing8) - .constrainAs(titleRef) { - start.linkTo(iconRef.end) - top.linkTo(parent.top) - - width = Dimension.fillToConstraints.atLeast(dp = titleRequiredMinWidth) - - when (state) { - is TokenItemState.Content -> end.linkTo(fiatAmountRef.start) - is TokenItemState.Draggable -> end.linkTo(nonFiatContentRef.start) - is TokenItemState.Unreachable, - is TokenItemState.NoAddress, - -> { - end.linkTo(nonFiatContentRef.start) - bottom.linkTo(parent.bottom) - } - else -> Unit - } - }, + .padding(bottom = TangemTheme.dimens.spacing2), ) TokenFiatAmount( state = state.fiatAmountState, isBalanceHidden = isBalanceHidden, - modifier = Modifier.constrainAs(fiatAmountRef) { - top.linkTo(parent.top) - end.linkTo(parent.end) - - width = Dimension.fillToConstraints.atMostWrapContent - - if (state is TokenItemState.Content) { - start.linkTo(titleRef.end) - } - }, + modifier = Modifier + .layoutId(layoutId = LayoutId.FIAT_AMOUNT) + .padding(bottom = TangemTheme.dimens.spacing2), ) - val marginBetweenRows = TangemTheme.dimens.spacing2 TokenCryptoAmount( state = state.cryptoAmountState, isBalanceHidden = isBalanceHidden, modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing8) - .constrainAs(cryptoAmountRef) { - start.linkTo(iconRef.end) - top.linkTo(titleRef.bottom, marginBetweenRows) - bottom.linkTo(parent.bottom) - - when (state) { - is TokenItemState.Content -> { - end.linkTo(priceChangeRef.start) - width = Dimension.fillToConstraints.atMostWrapContent - } - is TokenItemState.Draggable -> { - end.linkTo(nonFiatContentRef.start) - width = Dimension.fillToConstraints - } - else -> Unit - } - }, + .layoutId(layoutId = LayoutId.CRYPTO_AMOUNT) + .padding(horizontal = TangemTheme.dimens.spacing8), ) - val priceChangeRequiredMinWidth by remember(rootWidth) { - derivedStateOf { with(density) { rootWidth.toDp().times(other = 0.16f) } } - } TokenPriceChange( state = state.priceChangeState, - modifier = Modifier.constrainAs(priceChangeRef) { - top.linkTo(fiatAmountRef.bottom, marginBetweenRows) - end.linkTo(anchor = parent.end) - bottom.linkTo(parent.bottom) - - when (state.priceChangeState) { - is TokenItemState.PriceChangeState.Content, - is TokenItemState.PriceChangeState.Unknown, - -> { - start.linkTo(cryptoAmountRef.end) - width = Dimension.fillToConstraints - .atLeast(priceChangeRequiredMinWidth) - } - else -> Unit - } - }, + modifier = Modifier.layoutId(layoutId = LayoutId.PRICE_CHANGE), ) NonFiatContentBlock( state = state, reorderableTokenListState = reorderableTokenListState, - modifier = Modifier.constrainAs(nonFiatContentRef) { - centerVerticallyTo(parent) - end.linkTo(parent.end) - }, - ) - } -} - -@Composable -private inline fun BaseContainer( - modifier: Modifier = Modifier, - crossinline content: @Composable ConstraintLayoutScope.() -> Unit, -) { - Box( - modifier = modifier - .defaultMinSize(minHeight = TangemTheme.dimens.size68) - .background(color = TangemTheme.colors.background.primary), - ) { - ConstraintLayout( - modifier = Modifier - .fillMaxWidth() - .padding(all = TangemTheme.dimens.spacing14), - content = content, + modifier = Modifier.layoutId(layoutId = LayoutId.NON_FIAT_CONTENT), ) } } @@ -196,47 +105,299 @@ private fun Modifier.tokenClickable(state: TokenItemState): Modifier = composed } } -// region preview -@Preview +/** + * IMPORTANT! All margins that used between children setup like as children paddings. + */ +@Suppress("LongMethod") @Composable -private fun Preview_Tokens_LightTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) { +private fun CustomContainer(state: TokenItemState, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val density = LocalDensity.current + val dimens = TangemTheme.dimens + + Layout(content = content, modifier = modifier) { measurables, constraints -> + + val layoutWidth = constraints.maxWidth + val layoutPadding = with(density) { dimens.size14.roundToPx() } + val layoutWidthWithPaddings = layoutWidth - 2 * layoutPadding + + val titleMinWidth = (layoutWidth * TITLE_MIN_WIDTH_COEFFICIENT).toInt() + val priceChangeMinWidth = (layoutWidth * PRICE_CHANGE_MIN_WIDTH_COEFFICIENT).toInt() + + val icon = measurables.measure(layoutId = LayoutId.ICON, constraints = constraints) + + /* + * Title width take the whole REMAINING space. + * If FiatAmount took the whole free space, then Title will has min width. + */ + val title: Placeable + + // FiatAmount width must take the whole free space but is not greater the Title min size + var fiatAmount: Placeable? = null + + // CryptoAmount width must take the whole free space but is not greater the PriceChange min size + var cryptoAmount: Placeable? = null + + /* + * PriceChange width take the whole REMAINING space. + * If CryptoAmount took the whole free space, then PriceChange will has min width. + */ + val priceChange: Placeable? + + val nonFiatContent = measurables.measure(layoutId = LayoutId.NON_FIAT_CONTENT, constraints = constraints) + + var firstRowRemainingFreeSpace: Int? = null + var secondRowRemainingFreeSpace: Int? = null + + when (state) { + is TokenItemState.Content, + is TokenItemState.Loading, + is TokenItemState.Locked, + -> { + fiatAmount = measurables.measureFiatAmount( + state = state, + maxWidth = layoutWidthWithPaddings - icon.width - titleMinWidth, + defaultConstraints = constraints, + ) + + cryptoAmount = measurables.measureCryptoAmount( + state = state, + maxWidth = layoutWidthWithPaddings - icon.width - priceChangeMinWidth, + defaultConstraints = constraints, + ) + + firstRowRemainingFreeSpace = layoutWidthWithPaddings - icon.width - fiatAmount.width + secondRowRemainingFreeSpace = layoutWidthWithPaddings - icon.width - cryptoAmount.width + } + is TokenItemState.Draggable -> { + cryptoAmount = measurables.measureCryptoAmount( + state = state, + maxWidth = layoutWidthWithPaddings - icon.width - nonFiatContent.width, + defaultConstraints = constraints, + ) + + firstRowRemainingFreeSpace = layoutWidthWithPaddings - icon.width - nonFiatContent.width + } + is TokenItemState.NoAddress, + is TokenItemState.Unreachable, + -> { + firstRowRemainingFreeSpace = layoutWidthWithPaddings - icon.width - nonFiatContent.width + } + } + + title = measurables.measureTitle( + state = state, + minWidth = titleMinWidth, + remainingFreeSpace = firstRowRemainingFreeSpace, + defaultConstraints = constraints, + ) + + priceChange = secondRowRemainingFreeSpace?.let { + measurables.measurePriceChange( + state = state, + minWidth = priceChangeMinWidth, + remainingFreeSpace = secondRowRemainingFreeSpace, + defaultConstraints = constraints, + ) + } + + val layoutHeight = calculateLayoutHeight( + state = state, + minLayoutHeight = with(density) { dimens.size68.roundToPx() }, + layoutPadding = layoutPadding, + betweenRowsPadding = with(density) { dimens.size2.roundToPx() }, + title = title, + fiatAmount = fiatAmount, + cryptoAmount = cryptoAmount, + priceChange = priceChange, + ) + + layout(width = constraints.maxWidth, height = layoutHeight) { + icon.placeRelative(x = layoutPadding, y = (layoutHeight - icon.height).div(other = 2)) + + title.placeRelative( + x = layoutPadding + icon.width, + y = when (state) { + is TokenItemState.NoAddress, + is TokenItemState.Unreachable, + -> (layoutHeight - title.height).div(other = 2) + else -> layoutPadding + }, + ) + + cryptoAmount?.placeRelative( + x = layoutPadding + icon.width, + y = layoutHeight - cryptoAmount.height - layoutPadding, + ) + + fiatAmount?.placeRelative(x = layoutWidth - fiatAmount.width - layoutPadding, y = layoutPadding) + + priceChange?.placeRelative( + x = layoutWidth - priceChange.width - layoutPadding, + y = layoutHeight - priceChange.height - layoutPadding, + ) + + nonFiatContent.placeRelative( + x = layoutWidth - nonFiatContent.width - layoutPadding, + y = (layoutHeight - nonFiatContent.height).div(other = 2), + ) + } + } +} + +private fun List.measureFiatAmount( + state: TokenItemState, + maxWidth: Int, + defaultConstraints: Constraints, +): Placeable { + return measure( + layoutId = LayoutId.FIAT_AMOUNT, + constraints = when (state) { + is TokenItemState.Content, + is TokenItemState.Draggable, + -> createConstrainsSafely(maxWidth = maxWidth) + else -> defaultConstraints + }, + ) +} + +private fun List.measureCryptoAmount( + state: TokenItemState, + maxWidth: Int, + defaultConstraints: Constraints, +): Placeable { + return measure( + layoutId = LayoutId.CRYPTO_AMOUNT, + constraints = when (state) { + is TokenItemState.Content, + is TokenItemState.Draggable, + -> createConstrainsSafely(maxWidth = maxWidth) + else -> defaultConstraints + }, + ) +} + +private fun List.measureTitle( + state: TokenItemState, + minWidth: Int, + remainingFreeSpace: Int, + defaultConstraints: Constraints, +): Placeable { + return measure( + layoutId = LayoutId.TITLE, + constraints = when (state) { + is TokenItemState.Content, + is TokenItemState.Draggable, + is TokenItemState.NoAddress, + is TokenItemState.Unreachable, + -> createDynamicConstrains(minWidth = minWidth, remainingFreeSpace = remainingFreeSpace) + else -> defaultConstraints + }, + ) +} + +private fun List.measurePriceChange( + state: TokenItemState, + minWidth: Int, + remainingFreeSpace: Int, + defaultConstraints: Constraints, +): Placeable { + return measure( + layoutId = LayoutId.PRICE_CHANGE, + constraints = when (state) { + is TokenItemState.Content, + -> createDynamicConstrains(minWidth = minWidth, remainingFreeSpace = remainingFreeSpace) + else -> defaultConstraints + }, + ) +} + +private fun List.measure(layoutId: LayoutId, constraints: Constraints): Placeable { + return requireNotNull( + value = firstOrNull { it.layoutId == layoutId }, + lazyMessage = { "Measurables[$layoutId] is null" }, + ).measure(constraints) +} + +private fun createDynamicConstrains(minWidth: Int, remainingFreeSpace: Int): Constraints { + return createConstrainsSafely( + minWidth = minWidth, + maxWidth = max(a = minWidth, b = remainingFreeSpace), + ) +} + +private fun createConstrainsSafely( + minWidth: Int = 0, + maxWidth: Int = Constraints.Infinity, + minHeight: Int = 0, + maxHeight: Int = Constraints.Infinity, +): Constraints { + return Constraints( + minWidth = minWidth.makeNotLessZero(), + maxWidth = maxWidth.makeNotLessZero(), + minHeight = minHeight.makeNotLessZero(), + maxHeight = maxHeight.makeNotLessZero(), + ) +} + +private fun Int.makeNotLessZero(): Int = max(a = 0, b = this) + +@Suppress("LongParameterList") +private fun calculateLayoutHeight( + state: TokenItemState, + minLayoutHeight: Int, + layoutPadding: Int, + betweenRowsPadding: Int, + title: Placeable, + fiatAmount: Placeable?, + cryptoAmount: Placeable?, + priceChange: Placeable?, +): Int { + val firstColumnHeight: Int + val secondColumnHeight: Int + + when (state) { + is TokenItemState.Content, + is TokenItemState.Loading, + is TokenItemState.Locked, + -> { + firstColumnHeight = 2 * layoutPadding + title.height + betweenRowsPadding + (cryptoAmount?.height ?: 0) + secondColumnHeight = 2 * layoutPadding + (fiatAmount?.height ?: 0) + betweenRowsPadding + + (priceChange?.height ?: 0) + } + is TokenItemState.Draggable, + is TokenItemState.NoAddress, + is TokenItemState.Unreachable, + -> { + firstColumnHeight = minLayoutHeight + secondColumnHeight = minLayoutHeight + } + } + + return max(firstColumnHeight, secondColumnHeight).coerceAtLeast(minLayoutHeight) +} + +@Preview(widthDp = 360) +@Composable +private fun Preview_CustomTokenItem_InLight(@PreviewParameter(TokenItemStateProvider::class) state: TokenItemState) { TangemTheme(isDark = false) { - TokenItem(state) + TokenItem(state = state) } } -@Preview -@Composable -private fun Preview_Tokens_DarkTheme(@PreviewParameter(TokenConfigProvider::class) state: TokenItemState) { - TangemTheme(isDark = true) { - TokenItem(state) - } -} - -private class TokenConfigProvider : CollectionPreviewParameterProvider( +private class TokenItemStateProvider : CollectionPreviewParameterProvider( collection = listOf( - WalletPreviewData.tokenItemVisibleState.copy( - cryptoAmountState = TokenItemState.CryptoAmountState.Content( - text = "5,41221467146712416241274127841274174213421 MATIC", - ), - ), - WalletPreviewData.tokenItemVisibleState.copy( - priceChangeState = TokenItemState.PriceChangeState.Content( - valueInPercent = "31231231231231231231223123123123212312312312.0%", - type = PriceChangeType.UP, - ), - ), - WalletPreviewData.tokenItemVisibleState.copy( - cryptoAmountState = TokenItemState.CryptoAmountState.Content( - text = "5,41221467146712416241274127841274174213421 MATIC", - ), - priceChangeState = TokenItemState.PriceChangeState.Content( - valueInPercent = "31231231231231231231223123123123212312312312.0%", - type = PriceChangeType.UP, - ), - ), WalletPreviewData.tokenItemVisibleState.copy( iconState = WalletPreviewData.coinIconState.copy(showCustomBadge = true), + titleState = TokenItemState.TitleState.Content( + text = "PolygonPolygonPolygonPolygonPolygonPolygon", + hasPending = true, + ), + fiatAmountState = TokenItemState.FiatAmountState.Content(text = "3213123123321312312312312312 $"), + cryptoAmountState = TokenItemState.CryptoAmountState.Content(text = "5,4123123213123123123123123123 MATIC"), + priceChangeState = TokenItemState.PriceChangeState.Content( + valueInPercent = "2365723643724723423742342374623642374723472342342.0%", + type = PriceChangeType.UP, + ), ), WalletPreviewData.tokenItemUnreachableState, WalletPreviewData.tokenItemNoAddressState, @@ -247,6 +408,4 @@ private class TokenConfigProvider : CollectionPreviewParameterProvider