Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-09 17:43:43 +03:00
parent 236794fa00
commit ce5f535ac5
15 changed files with 1385 additions and 34 deletions

View file

@ -1707,6 +1707,8 @@
<string name="token_button_unavailability_reason_sell_unavailable">Selling %s is not supported by current providers, but we are working to add more options.</string>
<string name="token_button_unavailability_reason_staking_unavailable">Staking %s is not supported by current providers, but we are working to add more options.</string>
<string name="token_button_unavailability_reason_yield_supply_approval">Approval has been revoked. Your funds remain in Yield mode. To perform actions, please go to Yield mode and grant permission again.</string>
<string name="token_details_balance_available">Available balance</string>
<string name="token_details_balance_total">Total balance</string>
<string name="token_details_generate_xpub">Generate XPUB</string>
<string name="token_details_hide_alert_hide">Hide</string>
<string name="token_details_hide_alert_message">You are about to hide this token from the main screen. You can add it back anytime through the manage tokens page.</string>

View file

@ -42,10 +42,11 @@ fun CurrencyIcon(
withFixedSize: Boolean = true,
iconSize: Dp = 36.dp,
) {
val outerSize = iconSize + 4.dp
Box(
modifier = modifier
.conditional(withFixedSize) {
size(size = 40.dp)
size(size = outerSize)
},
) {
val iconModifier = Modifier

View file

@ -32,6 +32,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.common.ui.tokens.getUnavailabilityReasonText
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
@ -104,7 +105,10 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.TokenDetailsExpressStatusFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceLoadingTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokendetails.impl.R
@ -176,6 +180,7 @@ internal class TokenDetailsModel @Inject constructor(
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletIconUMConverter: WalletIconUMConverter,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val designFeatureToggles: DesignFeatureToggles,
private val redesignStateController: TokenDetailsStateController,
) : Model(),
TokenDetailsClickIntents,
@ -197,6 +202,7 @@ internal class TokenDetailsModel @Inject constructor(
private val stakingJobHolder = JobHolder()
private val yieldSupplyBalanceJobHolder = JobHolder()
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private val redesignBalanceJobHolder = JobHolder()
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
private var account: Account.CryptoPortfolio? = null
@ -278,10 +284,8 @@ internal class TokenDetailsModel @Inject constructor(
}
init {
initRedesignState()
initRedesign()
updateTopBarMenu()
updateRedesignTopBarMenu()
observeRedesignTopBarTitle()
initButtons()
updateContent()
handleBalanceHiding()
@ -893,6 +897,11 @@ internal class TokenDetailsModel @Inject constructor(
override fun onRefreshSwipe(isRefreshing: Boolean) {
uiState.value = stateFactory.getRefreshingState()
redesignStateController.update(
SetBalanceLoadingTransformer(
currencyIconState = redesignStateController.value.balanceBlockUM.currencyIconState,
),
)
modelScope.launch(dispatchers.main) {
listOf(
@ -1368,6 +1377,29 @@ internal class TokenDetailsModel @Inject constructor(
// endregion Clore migration
private fun observeRedesignBalance() {
getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }
.distinctUntilChanged()
.combine(selectedAppCurrencyFlow) { status, appCurrency -> status to appCurrency }
.onEach { (status, appCurrency) ->
redesignStateController.update(
SetBalanceTransformer(
status = status,
appCurrency = appCurrency,
onToggleBalanceType = ::toggleRedesignBalanceType,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
.saveIn(redesignBalanceJobHolder)
}
private fun toggleRedesignBalanceType() {
redesignStateController.update(ToggleBalanceTypeTransformer())
}
private fun updateRedesignTopBarMenu() {
modelScope.launch(dispatchers.main) {
val hasDerivations = networkHasDerivationUseCase(
@ -1389,6 +1421,14 @@ internal class TokenDetailsModel @Inject constructor(
}
}
private fun initRedesign() {
if (!designFeatureToggles.isRedesignEnabled) return
initRedesignState()
observeRedesignBalance()
updateRedesignTopBarMenu()
observeRedesignTopBarTitle()
}
private fun initRedesignState() {
redesignStateController.update(
InitializeWithCryptoCurrencyTransformer(

View file

@ -4,6 +4,8 @@ import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tokendetails.impl.R
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -23,10 +25,25 @@ internal sealed class TokenDetailsBalanceBlockUM {
override val actionButtons: ImmutableList<TangemButtonUM>,
override val tokenBalanceTypeUM: TokenBalanceTypeUM,
override val currencyIconState: CurrencyIconState,
val displayCryptoBalance: TextReference,
val displayFiatBalance: TextReference,
val displayCryptoBalanceAll: TextReference,
val displayFiatBalanceAll: TextReference,
val displayCryptoBalanceAvailable: TextReference?,
val displayFiatBalanceAvailable: TextReference?,
val isBalanceFlickering: Boolean,
) : TokenDetailsBalanceBlockUM()
) : TokenDetailsBalanceBlockUM() {
val displayCryptoBalance: TextReference
get() = when (tokenBalanceTypeUM.type) {
TokenBalanceTypeUM.Type.ALL -> displayCryptoBalanceAll
TokenBalanceTypeUM.Type.AVAILABLE -> displayCryptoBalanceAvailable ?: displayCryptoBalanceAll
}
val displayFiatBalance: TextReference
get() = when (tokenBalanceTypeUM.type) {
TokenBalanceTypeUM.Type.ALL -> displayFiatBalanceAll
TokenBalanceTypeUM.Type.AVAILABLE -> displayFiatBalanceAvailable ?: displayFiatBalanceAll
}
}
data class Error(
override val actionButtons: ImmutableList<TangemButtonUM>,
@ -34,11 +51,11 @@ internal sealed class TokenDetailsBalanceBlockUM {
override val currencyIconState: CurrencyIconState,
) : TokenDetailsBalanceBlockUM()
fun copyActionButtons(buttons: ImmutableList<TangemButtonUM>): TokenDetailsBalanceBlockUM {
fun copyCurrencyIconState(iconState: CurrencyIconState): TokenDetailsBalanceBlockUM {
return when (this) {
is Content -> this.copy(actionButtons = buttons)
is Error -> this.copy(actionButtons = buttons)
is Loading -> this.copy(actionButtons = buttons)
is Content -> this.copy(currencyIconState = iconState)
is Error -> this.copy(currencyIconState = iconState)
is Loading -> this.copy(currencyIconState = iconState)
}
}
}
@ -54,11 +71,11 @@ internal sealed class TokenBalanceTypeUM {
data class Multiple(
override val type: Type,
val availableTypes: ImmutableList<Type>,
val onSelect: (Type) -> Unit,
val onSelect: () -> Unit,
) : TokenBalanceTypeUM()
enum class Type {
ALL,
AVAILABLE,
enum class Type(val text: TextReference) {
ALL(resourceReference(R.string.token_details_balance_total)),
AVAILABLE(resourceReference(R.string.token_details_balance_available)),
}
}

View file

@ -4,7 +4,12 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
@ -37,7 +42,22 @@ internal class TokenDetailsStateController @Inject constructor() {
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
actionButtons = persistentListOf(
TangemButtonUM(
text = resourceReference(R.string.tangempay_card_details_add_funds),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
TangemButtonUM(
text = resourceReference(R.string.common_transfer),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),

View file

@ -1,5 +1,6 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
@ -12,12 +13,16 @@ internal class InitializeWithCryptoCurrencyTransformer(
private val onBackClick: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy(
topAppBarUM = prevState.topAppBarUM.copy(
titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = cryptoCurrency.name),
subtitle = stringReference(cryptoCurrency.symbol),
onBackClick = onBackClick,
),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol),
)
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val iconState = CryptoCurrencyToIconStateConverter().convert(cryptoCurrency)
return prevState.copy(
topAppBarUM = prevState.topAppBarUM.copy(
titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = cryptoCurrency.name),
subtitle = stringReference(cryptoCurrency.symbol),
onBackClick = onBackClick,
),
balanceBlockUM = prevState.balanceBlockUM.copyCurrencyIconState(iconState),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol),
)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
internal class SetBalanceLoadingTransformer(
private val currencyIconState: CurrencyIconState,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val prevBalance = prevState.balanceBlockUM
return prevState.copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = prevBalance.actionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = currencyIconState,
),
)
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getTotalWithRewardsStakingBalance
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.defaultAmount
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
/**
* Maps [CryptoCurrencyStatus] into [TokenDetailsBalanceBlockUM] and sets it on the state.
*
* Produces [TokenDetailsBalanceBlockUM.Content] for loaded states,
* [TokenDetailsBalanceBlockUM.Loading] for loading,
* [TokenDetailsBalanceBlockUM.Error] for unreachable/no-amount/missed-derivation.
*/
internal class SetBalanceTransformer(
private val status: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val onToggleBalanceType: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val prev = prevState.balanceBlockUM
val balanceBlockUM = when (status.value) {
is CryptoCurrencyStatus.Loading -> TokenDetailsBalanceBlockUM.Loading(
actionButtons = prev.actionButtons,
tokenBalanceTypeUM = prev.tokenBalanceTypeUM,
currencyIconState = prev.currencyIconState,
)
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
is CryptoCurrencyStatus.Custom,
-> buildLoadedContent(prev)
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> TokenDetailsBalanceBlockUM.Error(
actionButtons = prev.actionButtons,
tokenBalanceTypeUM = prev.tokenBalanceTypeUM,
currencyIconState = prev.currencyIconState,
)
}
return prevState.copy(balanceBlockUM = balanceBlockUM)
}
private fun buildLoadedContent(prev: TokenDetailsBalanceBlockUM): TokenDetailsBalanceBlockUM.Content {
val stakingCryptoAmount =
(status.value.stakingBalance as? StakingBalance.Data)?.getTotalWithRewardsStakingBalance(
status.currency.network.rawId,
)
val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) }
val hasStaking = !stakingCryptoAmount.isNullOrZero()
val prevType = prev.tokenBalanceTypeUM
val tokenBalanceTypeUM = if (hasStaking) {
TokenBalanceTypeUM.Multiple(
type = (prevType as? TokenBalanceTypeUM.Multiple)?.type ?: TokenBalanceTypeUM.Type.ALL,
availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE),
onSelect = onToggleBalanceType,
)
} else {
TokenBalanceTypeUM.Single
}
return TokenDetailsBalanceBlockUM.Content(
actionButtons = prev.actionButtons,
currencyIconState = prev.currencyIconState,
tokenBalanceTypeUM = tokenBalanceTypeUM,
displayFiatBalanceAll = formatFiatStyled(
fiatAmount = computeTotal(status.value.fiatAmount, stakingFiatAmount),
),
displayCryptoBalanceAll = formatCrypto(
amount = computeTotal(status.value.amount, stakingCryptoAmount),
),
displayFiatBalanceAvailable = if (hasStaking) {
formatFiatStyled(fiatAmount = status.value.fiatAmount)
} else {
null
},
displayCryptoBalanceAvailable = if (hasStaking) {
formatCrypto(amount = status.value.amount)
} else {
null
},
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
)
}
private fun computeTotal(base: BigDecimal?, staking: BigDecimal?): BigDecimal? {
if (base == null) return null
return if (staking != null) base + staking else base
}
private fun formatFiatStyled(fiatAmount: BigDecimal?): TextReference {
if (fiatAmount == null) return stringReference(DASH_SIGN)
return fiatAmount.formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
).defaultAmount(
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
}
}
private fun formatCrypto(amount: BigDecimal?): TextReference {
if (amount == null) return stringReference(DASH_SIGN)
return stringReference(
amount.format { crypto(status.currency).defaultAmount() },
)
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
/**
* Toggles the balance type between [TokenBalanceTypeUM.Type.ALL] and [TokenBalanceTypeUM.Type.AVAILABLE].
*
* No-op if the current balance state is not [TokenDetailsBalanceBlockUM.Content] or its
* [TokenDetailsBalanceBlockUM.Content.tokenBalanceTypeUM] is not [TokenBalanceTypeUM.Multiple].
*/
internal class ToggleBalanceTypeTransformer : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val content = prevState.balanceBlockUM as? TokenDetailsBalanceBlockUM.Content ?: return prevState
val multiple = content.tokenBalanceTypeUM as? TokenBalanceTypeUM.Multiple ?: return prevState
val nextType = when (multiple.type) {
TokenBalanceTypeUM.Type.ALL -> TokenBalanceTypeUM.Type.AVAILABLE
TokenBalanceTypeUM.Type.AVAILABLE -> TokenBalanceTypeUM.Type.ALL
}
return prevState.copy(
balanceBlockUM = content.copy(
tokenBalanceTypeUM = multiple.copy(type = nextType),
),
)
}
}

View file

@ -3,14 +3,20 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountIconUM
@ -33,6 +39,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
import kotlinx.collections.immutable.persistentListOf
@ -41,10 +49,10 @@ import kotlinx.collections.immutable.persistentListOf
internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifier = Modifier) {
val topAppBarUM = tokenDetailsUM.topAppBarUM
// TODO [REDACTED_TASK_KEY] Token Details Make Balance with actions
val balanceBlockHeight = 200.dp
val partialCollapsedHeight = 0.dp
val expandedHeight = balanceBlockHeight + partialCollapsedHeight
val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() }
val topBarHeight = 64.dp
val partialCollapsedHeight = topBarHeight + statusBarHeight
val expandedHeight = TokenDetailsBalanceBlockHeight + partialCollapsedHeight
val behavior = rememberTangemExitUntilCollapsedScrollBehavior(
expandedHeight = expandedHeight,
@ -62,11 +70,12 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi
TangemCollapsingTopBar(
state = behavior.state,
collapsingPart = {
// [REDACTED_TASK_KEY] Token Details Make Balance with actions
Box(
TokenDetailsBalanceBlock(
balanceBlockUM = tokenDetailsUM.balanceBlockUM,
modifier = Modifier
.fillMaxWidth()
.height(balanceBlockHeight),
.statusBarsPadding()
.padding(top = topBarHeight),
)
},
body = {

View file

@ -0,0 +1,239 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.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.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.button.action.ActionButtons
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.features.tokendetails.impl.R
import kotlinx.collections.immutable.persistentListOf
private val CurrencyIconSize: Dp = 70.dp
private val NetworkBadgeSize: Dp = 24.dp
internal val TokenDetailsBalanceBlockHeight: Dp = 404.dp
@Composable
internal fun TokenDetailsBalanceBlock(balanceBlockUM: TokenDetailsBalanceBlockUM, modifier: Modifier = Modifier) {
val rootBackground by LocalRootBackgroundColor.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x10),
) {
CurrencyIcon(
state = balanceBlockUM.currencyIconState,
shouldDisplayNetwork = true,
iconSize = CurrencyIconSize,
networkBadgeSize = NetworkBadgeSize,
networkBadgeBackground = rootBackground,
)
SpacerH(TangemTheme.dimens2.x3)
when (balanceBlockUM) {
is TokenDetailsBalanceBlockUM.Content -> ContentBody(state = balanceBlockUM)
is TokenDetailsBalanceBlockUM.Loading -> LoadingBody()
is TokenDetailsBalanceBlockUM.Error -> ErrorBody()
}
SpacerH(TangemTheme.dimens2.x10)
ActionButtons(buttons = balanceBlockUM.actionButtons)
}
}
@Composable
private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content) {
AnimatedContent(
targetState = state.tokenBalanceTypeUM.type,
label = "Token balance type",
) { currentType ->
val tokenBalanceTypeUM = state.tokenBalanceTypeUM
when (tokenBalanceTypeUM) {
is TokenBalanceTypeUM.Multiple -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
modifier = Modifier.clickable(onClick = tokenBalanceTypeUM.onSelect),
) {
Text(
text = currentType.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_sort_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.secondary,
modifier = Modifier.size(TangemTheme.dimens2.x4),
)
}
TokenBalanceTypeUM.Single -> Text(
text = currentType.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
}
SpacerH(TangemTheme.dimens2.x2)
Text(
text = state.displayFiatBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
)
SpacerH(TangemTheme.dimens2.x2_5)
Text(
text = state.displayCryptoBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
@Composable
private fun LoadingBody() {
Text(
text = TokenBalanceTypeUM.Type.ALL.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
SpacerH(TangemTheme.dimens2.x2)
TextShimmer(
style = TangemTheme.typography2.titleRegular44,
text = "$1234567890",
radius = TangemTheme.dimens2.x6,
)
SpacerH(TangemTheme.dimens2.x2)
TextShimmer(
style = TangemTheme.typography2.bodySemibold16,
text = "12345.67",
radius = TangemTheme.dimens2.x4,
)
}
@Composable
private fun ErrorBody() {
Text(
text = "",
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
)
SpacerH(TangemTheme.dimens2.x2_5)
Text(
text = "",
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenDetailsBalanceBlock_Preview(
@PreviewParameter(PreviewProvider::class) params: TokenDetailsBalanceBlockUM,
) {
TangemThemePreviewRedesign {
TokenDetailsBalanceBlock(
balanceBlockUM = params,
modifier = Modifier.background(TangemTheme.colors2.surface.level2),
)
}
}
private class PreviewProvider : PreviewParameterProvider<TokenDetailsBalanceBlockUM> {
private val previewActionButtons = persistentListOf(
TangemButtonUM(
text = stringReference("Add funds"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_down_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
TangemButtonUM(
text = stringReference("Transfer"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_up_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
)
override val values: Sequence<TokenDetailsBalanceBlockUM>
get() = sequenceOf(
TokenDetailsBalanceBlockUM.Content(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = TokenBalanceTypeUM.Type.ALL,
availableTypes = persistentListOf(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
),
onSelect = { },
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("0.0613884 BTC"),
displayFiatBalanceAll = stringReference("$12,380.94"),
displayCryptoBalanceAvailable = stringReference("0.05 BTC"),
displayFiatBalanceAvailable = stringReference("$10,000.00"),
isBalanceFlickering = false,
),
TokenDetailsBalanceBlockUM.Content(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("123.456 USDT"),
displayFiatBalanceAll = stringReference("$123.45"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
TokenDetailsBalanceBlockUM.Loading(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
TokenDetailsBalanceBlockUM.Error(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
)
}
// endregion

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
@ -99,7 +100,8 @@ class InitializeWithCryptoCurrencyTransformerTest {
// THEN — only top bar title/subtitle/onBackClick and marketPriceBlockState are touched
assertThat(result.topAppBarUM.menuItems).isEqualTo(state.topAppBarUM.menuItems)
assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM)
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(state.balanceBlockUM.actionButtons)
assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(state.balanceBlockUM.tokenBalanceTypeUM)
assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
@ -113,7 +115,11 @@ class InitializeWithCryptoCurrencyTransformerTest {
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = mockk(relaxed = true),
),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),

View file

@ -0,0 +1,143 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SetBalanceLoadingTransformerTest {
private val currencyIconState: CurrencyIconState = mockk(relaxed = true)
@Test
fun `GIVEN any state WHEN transform THEN balance block is Loading`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN currency icon state WHEN transform THEN Loading block carries that icon state`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.currencyIconState).isSameInstanceAs(currencyIconState)
}
@Test
fun `GIVEN state with action buttons WHEN transform THEN action buttons are preserved`() {
// GIVEN
val buttons = persistentListOf(
TangemButtonUM(
text = stringReference("Test"),
onClick = {},
isEnabled = true,
type = TangemButtonType.Secondary,
),
)
val state = initialState(actionButtons = buttons)
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(buttons)
}
@Test
fun `GIVEN any state WHEN transform THEN balance type is Single`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single)
}
@Test
fun `GIVEN Content balance block WHEN transform THEN switches to Loading`() {
// GIVEN
val contentState = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("1.0 BTC"),
displayFiatBalanceAll = stringReference("$50,000"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
)
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(contentState)
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
private fun initialState(
actionButtons: ImmutableList<TangemButtonUM> = persistentListOf(),
): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = actionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,481 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.common.getTotalWithRewardsStakingBalance
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class SetBalanceTransformerTest {
private val onToggleBalanceType: () -> Unit = mockk(relaxed = true)
private val appCurrency: AppCurrency = AppCurrency.Default
private val network: Network = mockk(relaxed = true) {
every { rawId } returns "ethereum"
}
private val currency: CryptoCurrency = mockk(relaxed = true) {
every { this@mockk.network } returns this@SetBalanceTransformerTest.network
every { symbol } returns "ETH"
}
@BeforeEach
fun setup() {
mockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance)
}
@AfterEach
fun teardown() {
unmockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance)
}
// region Status type → BalanceBlock type mapping
@Test
fun `GIVEN Loading status WHEN transform THEN balance block is Loading`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.Loading)
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN Loaded status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN NoQuote status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(noQuoteValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN NoAccount status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(noAccountValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN Custom status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(customValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN Unreachable status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(
CryptoCurrencyStatus.Unreachable(priceChange = null, fiatRate = null, networkAddress = null),
)
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
@Test
fun `GIVEN NoAmount status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.NoAmount(priceChange = null, fiatRate = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
@Test
fun `GIVEN MissedDerivation status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
// endregion
// region Action buttons & icon preservation
@Test
fun `GIVEN any loaded status WHEN transform THEN action buttons are preserved`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(initialState().balanceBlockUM.actionButtons)
}
@Test
fun `GIVEN any loaded status WHEN transform THEN currency icon state is preserved`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.currencyIconState)
.isEqualTo(initialState().balanceBlockUM.currencyIconState)
}
// endregion
// region Staking / balance type
@Test
fun `GIVEN loaded status without staking WHEN transform THEN balance type is Single`() {
// GIVEN
val status = createStatus(loadedValue(stakingBalance = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single)
}
@Test
fun `GIVEN loaded status with staking WHEN transform THEN balance type is Multiple`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.tokenBalanceTypeUM).isInstanceOf(TokenBalanceTypeUM.Multiple::class.java)
}
@Test
fun `GIVEN loaded status with staking WHEN transform THEN available balance types include ALL and AVAILABLE`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.availableTypes).containsExactly(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
)
}
@Test
fun `GIVEN staking balance WHEN Multiple onSelect invoked THEN onToggleBalanceType is called`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
multiple.onSelect()
// THEN
verify(exactly = 1) { onToggleBalanceType.invoke() }
}
@Test
fun `GIVEN staking and previous Multiple type AVAILABLE WHEN transform THEN selected type is preserved`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
val prevContent = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = TokenBalanceTypeUM.Type.AVAILABLE,
availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE),
onSelect = {},
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference(""),
displayFiatBalanceAll = stringReference(""),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
)
val state = initialState().copy(balanceBlockUM = prevContent)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE)
}
// endregion
// region Balance flickering
@Test
fun `GIVEN CACHE source WHEN transform THEN isBalanceFlickering is true`() {
// GIVEN
val sources = CryptoCurrencyStatus.Sources(
networkSource = StatusSource.CACHE,
quoteSource = StatusSource.CACHE,
stakingBalanceSource = StatusSource.CACHE,
)
val status = createStatus(loadedValue(sources = sources))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceFlickering).isTrue()
}
@Test
fun `GIVEN ACTUAL source WHEN transform THEN isBalanceFlickering is false`() {
// GIVEN
val sources = CryptoCurrencyStatus.Sources(
networkSource = StatusSource.ACTUAL,
quoteSource = StatusSource.ACTUAL,
stakingBalanceSource = StatusSource.ACTUAL,
)
val status = createStatus(loadedValue(sources = sources))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceFlickering).isFalse()
}
// endregion
// region No staking → available balances
@Test
fun `GIVEN loaded without staking WHEN transform THEN available balances are null`() {
// GIVEN
val status = createStatus(loadedValue(stakingBalance = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayCryptoBalanceAvailable).isNull()
assertThat(content.displayFiatBalanceAvailable).isNull()
}
@Test
fun `GIVEN staking balance WHEN transform THEN available balances are not null`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayCryptoBalanceAvailable).isNotNull()
assertThat(content.displayFiatBalanceAvailable).isNotNull()
}
// endregion
// region Unrelated fields preserved
@Test
fun `GIVEN any status WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = initialState()
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
// endregion
private fun createTransformer(status: CryptoCurrencyStatus) = SetBalanceTransformer(
status = status,
appCurrency = appCurrency,
onToggleBalanceType = onToggleBalanceType,
)
private fun createStatus(value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(
amount: BigDecimal = BigDecimal("10.5"),
fiatAmount: BigDecimal = BigDecimal("21000"),
fiatRate: BigDecimal = BigDecimal("2000"),
stakingBalance: StakingBalance? = null,
sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(),
): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = fiatAmount,
fiatRate = fiatRate,
priceChange = BigDecimal("2.5"),
stakingBalance = stakingBalance,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = sources,
)
private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote(
amount = BigDecimal("5.0"),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun noAccountValue(): CryptoCurrencyStatus.NoAccount = CryptoCurrencyStatus.NoAccount(
amountToCreateAccount = BigDecimal("0.01"),
fiatAmount = BigDecimal.ZERO,
priceChange = null,
fiatRate = BigDecimal("2000"),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun customValue(): CryptoCurrencyStatus.Custom = CryptoCurrencyStatus.Custom(
amount = BigDecimal("100"),
fiatAmount = null,
fiatRate = null,
priceChange = null,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,203 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class ToggleBalanceTypeTransformerTest {
private val transformer = ToggleBalanceTypeTransformer()
// region Toggle logic
@Test
fun `GIVEN Multiple with ALL WHEN transform THEN type switches to AVAILABLE`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE)
}
@Test
fun `GIVEN Multiple with AVAILABLE WHEN transform THEN type switches to ALL`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.AVAILABLE)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL)
}
@Test
fun `GIVEN Multiple with ALL WHEN transform twice THEN type returns to ALL`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(transformer.transform(state))
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL)
}
// endregion
// region No-op cases
@Test
fun `GIVEN Loading balance block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState()
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
@Test
fun `GIVEN Error balance block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Error(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
@Test
fun `GIVEN Content with Single balance type WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("1.0 ETH"),
displayFiatBalanceAll = stringReference("$2,000"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
// endregion
// region Unrelated fields preserved
@Test
fun `GIVEN any togglable state WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
@Test
fun `GIVEN togglable state WHEN transform THEN balance content fields besides type are preserved`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
val originalContent = state.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
// WHEN
val result = transformer.transform(state)
// THEN
val resultContent = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(resultContent.actionButtons).isEqualTo(originalContent.actionButtons)
assertThat(resultContent.currencyIconState).isEqualTo(originalContent.currencyIconState)
assertThat(resultContent.displayCryptoBalanceAll).isEqualTo(originalContent.displayCryptoBalanceAll)
assertThat(resultContent.displayFiatBalanceAll).isEqualTo(originalContent.displayFiatBalanceAll)
assertThat(resultContent.isBalanceFlickering).isEqualTo(originalContent.isBalanceFlickering)
}
// endregion
private fun stateWithContent(type: TokenBalanceTypeUM.Type): TokenDetailsUM {
return initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = type,
availableTypes = persistentListOf(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
),
onSelect = {},
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("10.5 ETH"),
displayFiatBalanceAll = stringReference("$21,000"),
displayCryptoBalanceAvailable = stringReference("9.0 ETH"),
displayFiatBalanceAvailable = stringReference("$18,000"),
isBalanceFlickering = false,
),
)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}