Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-10 18:28:54 +03:00
commit 6b9fc4a3ce
1058 changed files with 48197 additions and 12718 deletions

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -31,6 +32,7 @@ import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags
import com.tangem.domain.models.account.AccountName
@Composable
@ -48,7 +50,9 @@ fun PortfolioSelectRow(
leftContent()
val leftText = if (state.isAccountMode) R.string.account_details_title else R.string.wc_common_wallet
Text(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.testTag(WalletConnectBottomSheetTestTags.WALLET_NAME_TITLE),
text = stringResourceSafe(leftText),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -66,7 +70,9 @@ fun PortfolioSelectRow(
Text(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 4.dp),
modifier = Modifier
.padding(horizontal = 4.dp)
.testTag(WalletConnectBottomSheetTestTags.WALLET_NAME),
text = state.name.resolveReference(),
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,

View file

@ -38,6 +38,7 @@ class AmountStateConverter(
private val iconStateConverter: CryptoCurrencyToIconStateConverter,
private val isBalanceHidden: Boolean,
private val accountTitleUM: AccountTitleUM,
private val isMaxButtonVisible: Boolean = true,
) : Converter<AmountParameters, AmountState> {
private val amountFieldConverter by lazy(LazyThreadSafetyMode.NONE) {
@ -72,6 +73,7 @@ class AmountStateConverter(
amountTextField = amountFieldConverter.convert(value.value),
isPrimaryButtonEnabled = false,
appCurrency = appCurrency,
isMaxButtonVisible = isMaxButtonVisible,
)
}
}

View file

@ -24,6 +24,7 @@ sealed class AmountState {
* @param isEditingDisabled indicated whether amount is editable
* @param reduceAmountBy reduces amount to be sent by specified value
* @param isIgnoreReduce ignores reduce amount value
* @param isMaxButtonVisible indicates whether the "Max" button is shown
*/
data class Data(
override val isPrimaryButtonEnabled: Boolean,
@ -37,6 +38,7 @@ sealed class AmountState {
val isEditingDisabled: Boolean = false,
val reduceAmountBy: BigDecimal = BigDecimal.ZERO,
val isIgnoreReduce: Boolean = false,
val isMaxButtonVisible: Boolean = true,
) : AmountState()
data object Empty : AmountState() {

View file

@ -88,6 +88,7 @@ internal fun LazyListScope.amountFieldV2(
@Composable
private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modifier: Modifier = Modifier) {
val tokenIconState = (amountUM as? AmountState.Data)?.tokenIconState ?: CurrencyIconState.Loading
val isMaxButtonVisible = (amountUM as? AmountState.Data)?.isMaxButtonVisible != false
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@ -106,22 +107,24 @@ private fun AmountInfo(amountUM: AmountState, onMaxAmountClick: () -> Unit, modi
amountUM = amountUM,
modifier = Modifier.weight(1f),
)
Text(
text = stringResourceSafe(R.string.send_max_amount),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(end = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.button.secondary)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onMaxAmountClick,
)
.padding(horizontal = 12.dp, vertical = 4.dp)
.testTag(SendScreenTestTags.MAX_BUTTON),
)
if (isMaxButtonVisible) {
Text(
text = stringResourceSafe(R.string.send_max_amount),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.padding(end = 16.dp)
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors.button.secondary)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = onMaxAmountClick,
)
.padding(horizontal = 12.dp, vertical = 4.dp)
.testTag(SendScreenTestTags.MAX_BUTTON),
)
}
}
}

View file

@ -49,6 +49,7 @@ import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.res.R as CoreResR
@ -123,10 +124,9 @@ private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Mo
.padding(end = TangemTheme.dimens2.x3),
)
Text(
text = state.titleUM.text.resolveReference(),
style = state.titleUM.style.textStyle,
color = state.titleUM.tone.color(state.type),
EarnBlockTitle(
titleUM = state.titleUM,
type = state.type,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_TOP)
.padding(end = TangemTheme.dimens2.x2),
@ -182,7 +182,7 @@ private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, o
TangemButton(
buttonUM = TangemButtonUM(
text = trailingUM.text,
type = type.buttonType(),
type = trailingUM.style.buttonType(type),
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
isEnabled = trailingUM.isEnabled,
@ -207,21 +207,37 @@ private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, o
)
}
}
is EarnBlockUM.TrailingUM.Icon -> {
TangemIcon(
tangemIconUM = TangemIconUM.Icon(
iconRes = trailingUM.tone.iconRes(),
tintReference = { trailingUM.tone.tint() },
),
modifier = Modifier
.layoutId(TangemRowLayoutId.TAIL)
.size(TangemTheme.dimens2.x6),
)
}
null -> Unit
}
}
@Composable
private fun EarnBlockTitle(titleUM: EarnBlockUM.TitleUM, type: Type, modifier: Modifier = Modifier) {
val titleText: @Composable () -> Unit = {
Text(
text = titleUM.text.resolveReference(),
style = titleUM.style.textStyle,
color = titleUM.tone.color(type),
)
}
val icon = titleUM.iconUM
if (icon == null) {
Box(modifier = modifier) { titleText() }
return
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
titleText()
Spacer(modifier = Modifier.width(TangemTheme.dimens2.x1))
TangemIcon(
tangemIconUM = TangemIconUM.Icon(
iconRes = icon.tone.iconRes(),
tintReference = { icon.tone.tint() },
),
modifier = Modifier.size(TangemTheme.dimens2.x4),
)
}
}
@Composable
private fun EarnBlockSubtitle(subtitle: EarnBlockUM.SubtitleUM.Text, type: Type, modifier: Modifier = Modifier) {
val textStyle = subtitle.style.textStyle
@ -297,9 +313,12 @@ private fun Type.accentStrongTint(): Color = when (this) {
Type.YieldSupply -> TangemTheme.colors2.text.status.positive
}
private fun Type.buttonType(): TangemButtonType = when (this) {
Type.Staking -> TangemButtonType.Accent
Type.YieldSupply -> TangemButtonType.Positive
private fun EarnBlockUM.TrailingUM.Button.Style.buttonType(type: Type): TangemButtonType = when (this) {
EarnBlockUM.TrailingUM.Button.Style.Default -> when (type) {
Type.Staking -> TangemButtonType.Accent
Type.YieldSupply -> TangemButtonType.Positive
}
EarnBlockUM.TrailingUM.Button.Style.Secondary -> TangemButtonType.Secondary
}
@Composable
@ -319,16 +338,16 @@ private fun EarnBlockUM.SubtitleUM.Tone.color(type: Type): Color = when (this) {
EarnBlockUM.SubtitleUM.Tone.Accent -> type.accentText()
}
private fun EarnBlockUM.TrailingUM.IconTone.iconRes(): Int = when (this) {
EarnBlockUM.TrailingUM.IconTone.Warning -> R.drawable.ic_alert_triangle_20
EarnBlockUM.TrailingUM.IconTone.Info -> R.drawable.ic_alert_circle_red_20
private fun EarnBlockUM.TitleUM.IconTone.iconRes(): Int = when (this) {
EarnBlockUM.TitleUM.IconTone.Warning -> R.drawable.ic_attention_default_24
EarnBlockUM.TitleUM.IconTone.Info -> R.drawable.ic_alert_circle_24
}
@Composable
@ReadOnlyComposable
private fun EarnBlockUM.TrailingUM.IconTone.tint(): Color = when (this) {
EarnBlockUM.TrailingUM.IconTone.Warning -> TangemTheme.colors2.graphic.status.attention
EarnBlockUM.TrailingUM.IconTone.Info -> TangemTheme.colors2.fill.neutral.secondary
private fun EarnBlockUM.TitleUM.IconTone.tint(): Color = when (this) {
EarnBlockUM.TitleUM.IconTone.Warning -> TangemTheme.colors2.graphic.status.attention
EarnBlockUM.TitleUM.IconTone.Info -> TangemTheme.colors2.graphic.neutral.secondary
}
@Composable
@ -359,7 +378,7 @@ private val EarnBlockUM.SubtitleUM.Style.textStyle: TextStyle
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun EarnBlock_Preview(@PreviewParameter(EarnBlockPreviewProvider::class) state: EarnBlockUM) {
private fun EarnBlock_Staking_Preview(@PreviewParameter(EarnBlockStakingPreviewProvider::class) state: EarnBlockUM) {
TangemThemePreviewRedesign {
EarnBlock(
state = state,
@ -368,7 +387,21 @@ private fun EarnBlock_Preview(@PreviewParameter(EarnBlockPreviewProvider::class)
}
}
private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider<EarnBlockUM>(
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun EarnBlock_YieldSupply_Preview(
@PreviewParameter(EarnBlockYieldSupplyPreviewProvider::class) state: EarnBlockUM,
) {
TangemThemePreviewRedesign {
EarnBlock(
state = state,
modifier = Modifier.padding(TangemTheme.dimens2.x4),
)
}
}
private class EarnBlockStakingPreviewProvider : CollectionPreviewParameterProvider<EarnBlockUM>(
collection = listOf(
EarnBlockUM.Loading,
EarnBlockUM.Content(
@ -376,7 +409,7 @@ private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider<Earn
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.staking_native),
text = resourceReference(CoreResR.string.common_stake),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Disabled,
),
@ -392,17 +425,17 @@ private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider<Earn
backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.token_details_staking_block_title),
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Accent,
text = resourceReference(CoreResR.string.common_staking),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stringReference("Average APR 5.24%"),
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Primary,
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
text = stringReference("Stake"),
text = resourceReference(CoreResR.string.common_stake),
),
onClick = {},
),
@ -411,7 +444,7 @@ private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider<Earn
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_staking_40),
titleUM = EarnBlockUM.TitleUM(
text = stringReference("Native staking"),
text = resourceReference(CoreResR.string.staking_enabled),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
@ -427,25 +460,146 @@ private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider<Earn
),
onClick = {},
),
),
)
private class EarnBlockYieldSupplyPreviewProvider : CollectionPreviewParameterProvider<EarnBlockUM>(
collection = listOf(
// Available — promo entry: AccentSoft background, "More" button
EarnBlockUM.Content(
type = Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = stringReference("Earn yield"),
style = EarnBlockUM.TitleUM.Style.Small,
tone = EarnBlockUM.TitleUM.Tone.Accent,
text = resourceReference(
id = CoreResR.string.yield_module_token_details_earn_notification_subtitle,
formatArgs = wrappedList("5.24"),
),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = stringReference("Start earning · 5.24%"),
style = EarnBlockUM.SubtitleUM.Style.Large,
tone = EarnBlockUM.SubtitleUM.Tone.Primary,
text = resourceReference(
CoreResR.string.yield_module_token_details_earn_notification_description,
),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
text = stringReference("More"),
text = resourceReference(CoreResR.string.common_more),
),
onClick = {},
),
// Content — yield enabled, "Details" button
EarnBlockUM.Content(
type = Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.yield_module_transaction_enter),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(
id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
text = resourceReference(CoreResR.string.details_title),
style = EarnBlockUM.TrailingUM.Button.Style.Secondary,
),
onClick = {},
),
// Content with Warning title icon
EarnBlockUM.Content(
type = Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Warning),
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(
id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
text = resourceReference(CoreResR.string.details_title),
style = EarnBlockUM.TrailingUM.Button.Style.Secondary,
),
onClick = {},
),
// Content with Info title icon
EarnBlockUM.Content(
type = Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
iconUM = EarnBlockUM.TitleUM.IconUM(tone = EarnBlockUM.TitleUM.IconTone.Info),
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(
id = CoreResR.string.yield_module_average_apy,
formatArgs = wrappedList("5.24"),
),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
),
trailingUM = EarnBlockUM.TrailingUM.Button(
text = resourceReference(CoreResR.string.details_title),
style = EarnBlockUM.TrailingUM.Button.Style.Secondary,
),
onClick = {},
),
// Processing.Enter — enabling, no trailing
EarnBlockUM.Content(
type = Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Glowing(iconRes = R.drawable.ic_yield_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.common_enabling),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Accent,
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive),
),
trailingUM = null,
),
// Processing.Exit — disabling, no trailing, plain icon
EarnBlockUM.Content(
type = Type.YieldSupply,
backgroundUM = EarnBlockUM.BackgroundUM.Surface,
iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_yield_disabling_40),
titleUM = EarnBlockUM.TitleUM(
text = resourceReference(CoreResR.string.common_yield_mode),
style = EarnBlockUM.TitleUM.Style.Large,
tone = EarnBlockUM.TitleUM.Tone.Primary,
),
subtitleUM = EarnBlockUM.SubtitleUM.Text(
text = resourceReference(CoreResR.string.common_disabling),
style = EarnBlockUM.SubtitleUM.Style.Small,
tone = EarnBlockUM.SubtitleUM.Tone.Disabled,
loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted),
),
trailingUM = null,
),
),
)
// endregion

View file

@ -39,9 +39,13 @@ sealed interface EarnBlockUM {
val text: TextReference,
val style: Style,
val tone: Tone,
val iconUM: IconUM? = null,
) {
enum class Style { Large, Small }
enum class Tone { Primary, Secondary, Disabled, Accent }
data class IconUM(val tone: IconTone)
enum class IconTone { Warning, Info }
}
@Immutable
@ -65,18 +69,15 @@ sealed interface EarnBlockUM {
data class Button(
val text: TextReference,
val isEnabled: Boolean = true,
) : TrailingUM
val style: Style = Style.Default,
) : TrailingUM {
enum class Style { Default, Secondary }
}
data class Balance(
val fiatValue: TextReference,
val cryptoValue: TextReference,
val isBalanceHidden: Boolean,
) : TrailingUM
data class Icon(
val tone: IconTone,
) : TrailingUM
enum class IconTone { Warning, Info }
}
}

View file

@ -1,12 +1,42 @@
package com.tangem.common.ui.expressStatus
import android.content.res.Configuration
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.R
import com.tangem.common.ui.expressStatus.state.ExpressLinkUM
import com.tangem.common.ui.expressStatus.state.ExpressStatusUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.atoms.text.TextEllipsis
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.isNullOrEmpty
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
fun LazyListScope.expressTransactionsItems(
expressTxs: PersistentList<ExpressTransactionStateUM>,
@ -19,28 +49,170 @@ fun LazyListScope.expressTransactionsItems(
) { index ->
val itemInfo = expressTxs[index].info
val (iconRes, tint) = when (itemInfo.iconState) {
ExpressTransactionStateIconUM.Warning -> {
R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention
}
ExpressTransactionStateIconUM.Error -> {
R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning
}
ExpressTransactionStateIconUM.Warning ->
R.drawable.ic_attention_default_24 to TangemTheme.colors2.graphic.status.attention
ExpressTransactionStateIconUM.Error ->
R.drawable.ic_alert_circle_24 to TangemTheme.colors2.graphic.status.warning
ExpressTransactionStateIconUM.None -> null to null
}
ExpressStatusItem(
title = itemInfo.title,
fromTokenIconState = itemInfo.fromCurrencyIcon,
toTokenIconState = itemInfo.toCurrencyIcon,
fromAmount = itemInfo.fromAmount,
fromSymbol = itemInfo.fromAmountSymbol,
toAmount = itemInfo.toAmount,
toSymbol = itemInfo.toAmountSymbol,
subtitle = itemInfo.subtitle,
onClick = itemInfo.onClick,
ExpressTransactionItem(
state = expressTxs[index],
infoIconRes = iconRes,
infoIconTint = tint,
modifier = modifier.animateItem(),
)
}
}
}
@Composable
private fun ExpressTransactionItem(
state: ExpressTransactionStateUM,
infoIconRes: Int?,
infoIconTint: Color?,
modifier: Modifier = Modifier,
) {
val info = state.info
Column(
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors2.surface.level3)
.clickable(onClick = info.onClick)
.padding(TangemTheme.dimens2.x4),
) {
TitleRow(
title = info.title.resolveReference(),
infoIconRes = infoIconRes,
infoIconTint = infoIconTint,
)
if (!info.subtitle.isNullOrEmpty()) {
Text(
text = info.subtitle.resolveReference(),
style = TangemTheme.typography2.captionRegular13,
color = TangemTheme.colors3.text.tertiary,
)
}
Spacer(Modifier.size(TangemTheme.dimens2.x3))
AmountsRow(info = info)
}
}
@Composable
private fun TitleRow(title: String, infoIconRes: Int?, infoIconTint: Color?) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary,
modifier = Modifier.weight(1f),
)
if (infoIconRes != null && infoIconTint != null) {
Icon(
painter = painterResource(infoIconRes),
contentDescription = null,
tint = infoIconTint,
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x3),
)
}
}
}
@Composable
private fun AmountsRow(info: ExpressTransactionStateInfoUM) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1_5),
) {
CurrencyIcon(
state = info.fromCurrencyIcon,
shouldDisplayNetwork = false,
modifier = Modifier.size(TangemTheme.dimens.size18),
)
EllipsisText(
text = info.fromAmount.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary,
ellipsis = TextEllipsis.OffsetEnd(info.fromAmountSymbol.length),
modifier = Modifier.weight(weight = 1f, fill = false),
)
Icon(
painter = painterResource(R.drawable.ic_forward_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.tertiary,
modifier = Modifier.size(TangemTheme.dimens.size18),
)
CurrencyIcon(
state = info.toCurrencyIcon,
shouldDisplayNetwork = false,
modifier = Modifier.size(TangemTheme.dimens.size18),
)
EllipsisText(
text = info.toAmount.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary,
ellipsis = TextEllipsis.OffsetEnd(info.toAmountSymbol.length),
modifier = Modifier.weight(weight = 1f, fill = false),
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ExpressTransactionItemPreview() {
TangemThemePreviewRedesign {
Column(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
modifier = Modifier.padding(TangemTheme.dimens2.x4),
) {
ExpressTransactionItem(
state = PreviewExpressTransactionState,
infoIconRes = null,
infoIconTint = null,
)
ExpressTransactionItem(
state = PreviewExpressTransactionState,
infoIconRes = R.drawable.ic_attention_default_24,
infoIconTint = TangemTheme.colors2.graphic.status.attention,
)
ExpressTransactionItem(
state = PreviewExpressTransactionState,
infoIconRes = R.drawable.ic_alert_circle_24,
infoIconTint = TangemTheme.colors2.graphic.status.warning,
)
}
}
}
private val PreviewExpressTransactionState: ExpressTransactionStateUM = object : ExpressTransactionStateUM {
override val info = ExpressTransactionStateInfoUM(
title = stringReference("Exchange by ChangeHero"),
status = ExpressStatusUM(
title = stringReference(""),
link = ExpressLinkUM.Empty,
statuses = persistentListOf(),
),
notification = null,
txId = "preview",
txExternalId = null,
txExternalUrl = null,
timestamp = 0L,
timestampFormatted = stringReference(""),
timestampAgoFormatted = stringReference("Confirming ~ 59 min ago"),
activeStatus = stringReference(""),
onGoToProviderClick = {},
onClick = {},
onDisposeExpressStatus = {},
iconState = ExpressTransactionStateIconUM.None,
toAmount = stringReference("0,11441958 BTC"),
toFiatAmount = null,
toAmountSymbol = "BTC",
toCurrencyIcon = CurrencyIconState.Loading,
fromAmount = stringReference("100 SOL"),
fromFiatAmount = null,
fromAmountSymbol = "SOL",
fromCurrencyIcon = CurrencyIconState.Loading,
)
}
// endregion

View file

@ -0,0 +1,46 @@
package com.tangem.common.ui.expressStatus
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import com.tangem.common.ui.R
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.PersistentList
fun LazyListScope.expressTransactionsItemsLegacy(
expressTxs: PersistentList<ExpressTransactionStateUM>,
modifier: Modifier = Modifier,
) {
items(
count = expressTxs.size,
key = { index -> expressTxs[index].info.txId },
contentType = { index -> expressTxs[index]::class.java },
) { index ->
val itemInfo = expressTxs[index].info
val (iconRes, tint) = when (itemInfo.iconState) {
ExpressTransactionStateIconUM.Warning -> {
R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention
}
ExpressTransactionStateIconUM.Error -> {
R.drawable.ic_alert_circle_24 to TangemTheme.colors.icon.warning
}
ExpressTransactionStateIconUM.None -> null to null
}
ExpressStatusItem(
title = itemInfo.title,
fromTokenIconState = itemInfo.fromCurrencyIcon,
toTokenIconState = itemInfo.toCurrencyIcon,
fromAmount = itemInfo.fromAmount,
fromSymbol = itemInfo.fromAmountSymbol,
toAmount = itemInfo.toAmount,
toSymbol = itemInfo.toAmountSymbol,
subtitle = itemInfo.subtitle,
onClick = itemInfo.onClick,
infoIconRes = iconRes,
infoIconTint = tint,
modifier = modifier.animateItem(),
)
}
}

View file

@ -12,5 +12,5 @@ data class ExpressTransactionsBlockState(
data class BottomSheetSlot(
val config: TangemBottomSheetConfig,
val content: @Composable () -> Unit,
val content: @Composable (extraContent: (@Composable () -> Unit)?) -> Unit,
)

View file

@ -196,6 +196,9 @@ private fun iconSetOf(blockchain: Blockchain): IconSet? = when (blockchain) {
Blockchain.Plasma,
Blockchain.PlasmaTestnet,
-> IconSet(active = R.drawable.img_plasma_22, greyedOut = R.drawable.ic_plasma_22)
Blockchain.Adi,
Blockchain.AdiTestnet,
-> IconSet(active = R.drawable.img_adi_22, greyedOut = R.drawable.ic_adi_22)
Blockchain.Playa3ull,
-> IconSet(active = R.drawable.img_playa3ull_22, greyedOut = R.drawable.ic_playa3ull_22)
Blockchain.Polkadot,

View file

@ -0,0 +1,98 @@
package com.tangem.common.ui.swap
import com.tangem.domain.models.currency.CryptoCurrency
import java.util.Locale
/**
* Resolves which currency goes first (base) and which goes second (quote) when displaying an
* exchange rate for a swap pair ([REDACTED_TASK_KEY]).
*
* Categories used by the rules:
* - **Stable** a [CryptoCurrency.Token] whose normalized symbol is in [STABLECOIN_RANKS].
* The symbol is normalized to uppercase and the suffix after `.` is stripped, so bridged
* variants (`USDC.E`, `USDT.e`, etc.) are matched against their underlying asset.
* - **Coin** a [CryptoCurrency.Coin] (any native coin: BTC, ETH, SOL, TRX, ...).
* - Anything else (a [CryptoCurrency.Token] outside the stable list) falls into the default
* branch and is treated as a regular token.
*
* Rules:
* - Stable Stable: base = the one ranked higher in [STABLECOIN_RANKS].
* - Coin Stable / Stable Coin: base is the coin.
* - Coin Coin with BTC or ETH: base is the other coin, quote is BTC/ETH.
* - ETH BTC (both directions): base = ETH, quote = BTC.
* - Otherwise (regular CoinCoin, any pair involving a non-stable Token): base = TO, quote = FROM.
*/
internal object SwapRateDirectionResolver {
private val STABLECOIN_RANKS: Map<String, Int> = listOf(
"USDT", "USDC", "USDe", "DAI", "USD1", "PYUSD", "RLUSD", "USDG", "USDf", "USDD",
).withIndex().associate { (rank, symbol) -> symbol.uppercase(Locale.ROOT) to rank }
private const val BTC_SYMBOL = "BTC"
private const val ETH_SYMBOL = "ETH"
fun resolve(from: CryptoCurrency, to: CryptoCurrency): SwapRateDirection {
val isFromStable = from.isStable()
val isToStable = to.isStable()
return when {
isFromStable && isToStable -> resolveStableToStable(from, to)
isFromStable -> SwapRateDirection(base = to, quote = from)
isToStable -> SwapRateDirection(base = from, quote = to)
from.isCoin() && to.isCoin() -> resolveCoinToCoin(from, to)
else -> SwapRateDirection(base = to, quote = from)
}
}
private fun resolveStableToStable(from: CryptoCurrency, to: CryptoCurrency): SwapRateDirection {
val fromRank = stableRank(from.symbol.normalizeStableSymbol())
val toRank = stableRank(to.symbol.normalizeStableSymbol())
return if (fromRank <= toRank) {
SwapRateDirection(base = from, quote = to)
} else {
SwapRateDirection(base = to, quote = from)
}
}
private fun resolveCoinToCoin(from: CryptoCurrency, to: CryptoCurrency): SwapRateDirection {
val fromSymbol = from.symbol.uppercaseRoot()
val toSymbol = to.symbol.uppercaseRoot()
val isFromBtcOrEth = fromSymbol.isBtcOrEth()
val isToBtcOrEth = toSymbol.isBtcOrEth()
return when {
isFromBtcOrEth && isToBtcOrEth -> resolveBtcEth(from, to, fromSymbol)
isFromBtcOrEth -> SwapRateDirection(base = to, quote = from)
isToBtcOrEth -> SwapRateDirection(base = from, quote = to)
else -> SwapRateDirection(base = to, quote = from)
}
}
private fun resolveBtcEth(from: CryptoCurrency, to: CryptoCurrency, fromSymbol: String): SwapRateDirection {
return if (fromSymbol == ETH_SYMBOL) {
SwapRateDirection(base = from, quote = to)
} else {
SwapRateDirection(base = to, quote = from)
}
}
private fun stableRank(symbol: String): Int = STABLECOIN_RANKS[symbol] ?: Int.MAX_VALUE
private fun CryptoCurrency.isStable(): Boolean {
return this is CryptoCurrency.Token && STABLECOIN_RANKS.containsKey(symbol.normalizeStableSymbol())
}
private fun CryptoCurrency.isCoin(): Boolean = this is CryptoCurrency.Coin
private fun String.isBtcOrEth(): Boolean = this == BTC_SYMBOL || this == ETH_SYMBOL
private fun String.uppercaseRoot(): String = uppercase(Locale.ROOT)
/**
* Drops bridge/wrapped suffix (e.g. `USDC.E` `USDC`, `USDT.e` `USDT`) before stable lookup.
* Bridged variants share the underlying asset's rank.
*/
private fun String.normalizeStableSymbol(): String = substringBefore('.').uppercaseRoot()
}
internal data class SwapRateDirection(val base: CryptoCurrency, val quote: CryptoCurrency)

View file

@ -0,0 +1,88 @@
package com.tangem.common.ui.swap
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.tangem.core.ui.extensions.appendSpace
import com.tangem.core.ui.format.bigdecimal.anyDecimals
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.StringsSigns
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.min
/**
* Formats a swap exchange rate as `1 {base} {rate} {quote}`.
*
* The base/quote choice follows the rules in [SwapRateDirectionResolver] ([REDACTED_TASK_KEY]).
*/
object SwapRateFormatter {
private const val MAX_DECIMALS_TO_SHOW = 8
private const val IF_ZERO_DECIMALS_TO_SHOW = 2
fun formatRate(from: CryptoCurrency, to: CryptoCurrency, fromAmount: BigDecimal, toAmount: BigDecimal): String {
val (base, quote, rate) = computeRate(
from = from,
to = to,
fromAmount = fromAmount,
toAmount = toAmount,
)
return buildString {
append(BigDecimal.ONE.format { crypto(symbol = base.symbol, decimals = 0).anyDecimals() })
append(StringsSigns.WHITE_SPACE)
append(StringsSigns.APPROXIMATE)
append(StringsSigns.WHITE_SPACE)
append(rate.format { crypto(quote) })
}
}
fun formatRateAnnotated(
from: CryptoCurrency,
to: CryptoCurrency,
fromAmount: BigDecimal,
toAmount: BigDecimal,
): AnnotatedString {
val (base, quote, rate) = computeRate(
from = from,
to = to,
fromAmount = fromAmount,
toAmount = toAmount,
)
return buildAnnotatedString {
append(BigDecimal.ONE.format { crypto(symbol = base.symbol, decimals = 0).anyDecimals() })
appendSpace()
append(StringsSigns.APPROXIMATE)
appendSpace()
append(rate.format { crypto(quote) })
}
}
private fun computeRate(
from: CryptoCurrency,
to: CryptoCurrency,
fromAmount: BigDecimal,
toAmount: BigDecimal,
): RateComputation {
val direction = SwapRateDirectionResolver.resolve(from, to)
val baseAmount: BigDecimal
val quoteAmount: BigDecimal
if (direction.base == from) {
baseAmount = fromAmount
quoteAmount = toAmount
} else {
baseAmount = toAmount
quoteAmount = fromAmount
}
val rate = if (baseAmount.signum() == 0) {
BigDecimal.ZERO
} else {
val rateDecimals = if (direction.quote.decimals == 0) IF_ZERO_DECIMALS_TO_SHOW else direction.quote.decimals
quoteAmount.divide(baseAmount, min(rateDecimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP)
}
return RateComputation(direction.base, direction.quote, rate)
}
private data class RateComputation(val base: CryptoCurrency, val quote: CryptoCurrency, val rate: BigDecimal)
}

View file

@ -0,0 +1,177 @@
package com.tangem.common.ui.tokenaction
import androidx.annotation.DrawableRes
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.haptic.HapticManager
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
private const val ACTION_BACKGROUND_ALPHA = .1f
/**
* Single-row token action ("Buy", "Receive", etc.) with accent icon, title, description and a
* customizable tail. Used in bottom sheets like Get Token / Add Funds and Add To Portfolio.
*
* @param iconRes leading 20dp icon drawn over an accent-colored circle
* @param title row primary text
* @param description row secondary text
* @param onClick single-click callback; row is non-interactive if `null`. Fires regardless
* of [isEnabled] gating is the caller's responsibility (pass `null` to
* make the row non-interactive)
* @param onLongClick long-press callback; pass `null` to disable long-press. Fires regardless
* of [isEnabled]
* @param isEnabled controls visual styling only (disabled-tier colors when `false`)
* @param tailContent content placed at the row's end. Defaults to a chevron-right icon.
*/
@Composable
fun TokenActionRow(
@DrawableRes iconRes: Int,
title: TextReference,
description: TextReference,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
isEnabled: Boolean = true,
tailContent: @Composable () -> Unit = { DefaultTokenActionRowChevron(isEnabled = isEnabled) },
) {
val hapticManager = LocalHapticManager.current
val accentColor = accentColor(isEnabled)
TangemRowContainer(
modifier = modifier
.background(
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
)
.clickableWithHaptic(
onClick = onClick,
onLongClick = onLongClick,
hapticManager = hapticManager,
),
) {
LeadingIcon(iconRes = iconRes, accentColor = accentColor)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
text = title.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = titleColor(isEnabled),
)
Text(
modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM),
text = description.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = descriptionColor(isEnabled),
)
Tail { tailContent() }
}
}
@Composable
private fun LeadingIcon(@DrawableRes iconRes: Int, accentColor: Color) {
Box(
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(40.dp)
.background(
color = accentColor.copy(alpha = ACTION_BACKGROUND_ALPHA),
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(id = iconRes),
contentDescription = null,
tint = accentColor,
)
}
}
@Composable
private fun Tail(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.layoutId(TangemRowLayoutId.TAIL)
.padding(start = TangemTheme.dimens2.x2)
.size(24.dp),
contentAlignment = Alignment.Center,
) {
content()
}
}
@Composable
private fun accentColor(isEnabled: Boolean): Color = if (isEnabled) {
TangemTheme.colors2.graphic.status.accent
} else {
TangemTheme.colors2.graphic.neutral.quaternary
}
@Composable
private fun titleColor(isEnabled: Boolean): Color = if (isEnabled) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors2.text.status.disabled
}
@Composable
private fun descriptionColor(isEnabled: Boolean): Color = if (isEnabled) {
TangemTheme.colors2.text.neutral.secondary
} else {
TangemTheme.colors2.text.status.disabled
}
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.clickableWithHaptic(
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
hapticManager: HapticManager,
): Modifier {
if (onClick == null) return this
return combinedClickable(
onClick = hapticManager.withHaptic(TangemHapticEffect.View.SegmentTick, onClick),
onLongClick = onLongClick?.let { hapticManager.withHaptic(TangemHapticEffect.View.LongPress, it) },
)
}
private fun HapticManager.withHaptic(effect: TangemHapticEffect, action: () -> Unit): () -> Unit = {
perform(effect)
action()
}
@Composable
private fun DefaultTokenActionRowChevron(isEnabled: Boolean) {
Icon(
modifier = Modifier.size(24.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24),
tint = if (isEnabled) {
TangemTheme.colors2.graphic.neutral.tertiary
} else {
TangemTheme.colors2.graphic.neutral.quaternary
},
contentDescription = null,
)
}

View file

@ -25,6 +25,7 @@ import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.optionOrNull
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.lib.crypto.BlockchainUtils
@ -244,14 +245,21 @@ class TokenItemStateConverter(
currencyStatus: CryptoCurrencyStatus,
stakingApyMap: Map<CryptoCurrency, StakingAvailability>,
): StakingLocalInfo {
val stakingAvailability = stakingApyMap[currencyStatus.currency] as? StakingAvailability.Available
val availability = stakingApyMap[currencyStatus.currency]
val option = availability?.optionOrNull
?: return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
val stakingBalance = currencyStatus.value.stakingBalance as? StakingBalance.Data
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
val p2pEthPoolBalance = stakingBalance as? StakingBalance.Data.P2PEthPool
val isActive = stakeKitBalance != null || p2pEthPoolBalance != null
val rateInfo = when (val stakingOptions = stakingAvailability.option) {
// Full = no free capacity: show the badge only for tokens that already have a stake.
if (availability is StakingAvailability.Full && !isActive) {
return StakingLocalInfo(rate = null, isActive = false, rewardType = null)
}
val rateInfo = when (val stakingOptions = option) {
is StakingOption.P2PEthPool -> {
RewardInfo(
rate = stakingOptions.apy,
@ -282,7 +290,7 @@ class TokenItemStateConverter(
return StakingLocalInfo(
rate = rateInfo?.rate,
isActive = stakeKitBalance != null || p2pEthPoolBalance != null,
isActive = isActive,
rewardType = rateInfo?.type,
)
}

View file

@ -1,6 +1,6 @@
package com.tangem.common.ui.userwallet.ext
import com.tangem.common.ui.R
import com.tangem.core.ui.R
import com.tangem.domain.models.wallet.UserWallet
fun walletInterationIcon(userWallet: UserWallet): Int? {

View file

@ -0,0 +1,62 @@
package com.tangem.common.ui.amountScreen.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.amountScreen.AmountScreenClickIntents
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class AmountStateConverterTest {
private val currency = mockk<CryptoCurrency.Coin> {
every { symbol } returns "ETH"
every { decimals } returns 18
every { name } returns "Ethereum"
}
private val status = CryptoCurrencyStatus(currency = currency, value = mockk(relaxed = true))
private val iconStateConverter = mockk<CryptoCurrencyToIconStateConverter> {
every { convert(any<CryptoCurrency>()) } returns CurrencyIconState.Loading
}
private val clickIntents = mockk<AmountScreenClickIntents>(relaxed = true)
private val accountTitleUM = mockk<AccountTitleUM>()
private fun convert(isMaxButtonVisible: Boolean = true): AmountState = AmountStateConverter(
clickIntents = clickIntents,
appCurrency = AppCurrency.Default,
cryptoCurrencyStatus = status,
maxEnterAmount = EnterAmountBoundary(
amount = BigDecimal.ONE,
fiatAmount = BigDecimal.TEN,
fiatRate = BigDecimal.ONE,
),
iconStateConverter = iconStateConverter,
isBalanceHidden = false,
accountTitleUM = accountTitleUM,
isMaxButtonVisible = isMaxButtonVisible,
).convert(AmountParameters(title = stringReference("Wallet"), value = ""))
@Test
fun `GIVEN no isMaxButtonVisible param WHEN convert THEN Data isMaxButtonVisible is true`() {
val result = convert()
assertThat((result as AmountState.Data).isMaxButtonVisible).isTrue()
}
@Test
fun `GIVEN isMaxButtonVisible false WHEN convert THEN Data isMaxButtonVisible is false`() {
val result = convert(isMaxButtonVisible = false)
assertThat((result as AmountState.Data).isMaxButtonVisible).isFalse()
}
}

View file

@ -0,0 +1,282 @@
package com.tangem.common.ui.components.currency.icon.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.extensions.networkIconResId
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CryptoCurrencyToIconStateConverterTest {
private val sut = CryptoCurrencyToIconStateConverter(isAvailable = true)
private val sutUnavailable = CryptoCurrencyToIconStateConverter(isAvailable = false)
@BeforeEach
fun setUp() {
mockkStatic("com.tangem.common.ui.extensions.NetworkIconExtKt")
}
@AfterEach
fun tearDown() {
unmockkAll()
}
// region public API — convert(value: CryptoCurrencyStatus)
@Test
fun `GIVEN coin status WHEN convert THEN return CoinIcon with currency and network fields`() {
val coin = buildCoin(
isTestnet = false,
isCustom = false,
iconUrl = "https://example.com/eth.png",
)
val status = buildStatus(currency = coin, isError = false)
val result = sut.convert(status)
assertThat(result).isEqualTo(
CurrencyIconState.CoinIcon(
url = "https://example.com/eth.png",
fallbackResId = NETWORK_ICON_RES_ID,
isGrayscale = false,
shouldShowCustomBadge = false,
),
)
}
@Test
fun `GIVEN token status WHEN convert THEN return TokenIcon with currency and network fields`() {
val token = buildToken(
isTestnet = false,
isCustom = false,
iconUrl = "https://example.com/usdt.png",
contractAddress = USDT_CONTRACT,
)
val status = buildStatus(currency = token, isError = false)
val result = sut.convert(status) as CurrencyIconState.TokenIcon
assertThat(result.url).isEqualTo("https://example.com/usdt.png")
assertThat(result.topBadgeIconResId).isEqualTo(NETWORK_ICON_RES_ID)
assertThat(result.isGrayscale).isFalse()
assertThat(result.shouldShowCustomBadge).isFalse()
}
// endregion
// region public API — convert(currency: CryptoCurrency)
@Test
fun `GIVEN coin currency WHEN convert without status THEN return CoinIcon with isUnreachable=false`() {
val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url")
val result = sut.convert(currency = coin) as CurrencyIconState.CoinIcon
assertThat(result.isGrayscale).isFalse()
assertThat(result.url).isEqualTo("url")
}
@Test
fun `GIVEN token currency WHEN convert without status THEN return TokenIcon with isErrorStatus=false`() {
val token = buildToken(
isTestnet = false,
isCustom = false,
iconUrl = "url",
contractAddress = USDT_CONTRACT,
)
val result = sut.convert(currency = token) as CurrencyIconState.TokenIcon
assertThat(result.isGrayscale).isFalse()
assertThat(result.url).isEqualTo("url")
}
// endregion
// region public API — convertCustom
@Test
fun `GIVEN coin status with custom flag WHEN convertCustom with forceGrayscale and badge off THEN both flags propagate`() {
val coin = buildCoin(isTestnet = false, isCustom = true, iconUrl = "url")
val status = buildStatus(currency = coin, isError = false)
val result = sut.convertCustom(
value = status,
forceGrayscale = true,
showCustomTokenBadge = false,
) as CurrencyIconState.CoinIcon
assertThat(result.isGrayscale).isTrue()
assertThat(result.shouldShowCustomBadge).isFalse()
}
@Test
fun `GIVEN token status WHEN convertCustom with forceGrayscale THEN TokenIcon is grayscale`() {
val token = buildToken(
isTestnet = false,
isCustom = false,
iconUrl = "url",
contractAddress = USDT_CONTRACT,
)
val status = buildStatus(currency = token, isError = false)
val result = sut.convertCustom(
value = status,
forceGrayscale = true,
showCustomTokenBadge = true,
) as CurrencyIconState.TokenIcon
assertThat(result.isGrayscale).isTrue()
}
// endregion
// region getIconStateForCoin — isGrayscale matrix
@Test
fun `GIVEN no override and live data WHEN convert coin THEN isGrayscale is false`() {
val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url")
val status = buildStatus(currency = coin, isError = false)
val result = sut.convert(status) as CurrencyIconState.CoinIcon
assertThat(result.isGrayscale).isFalse()
}
@Test
fun `GIVEN testnet network WHEN convert coin THEN isGrayscale is true`() {
val coin = buildCoin(isTestnet = true, isCustom = false, iconUrl = "url")
val status = buildStatus(currency = coin, isError = false)
val result = sut.convert(status) as CurrencyIconState.CoinIcon
assertThat(result.isGrayscale).isTrue()
}
@Test
fun `GIVEN error status WHEN convert coin THEN isGrayscale is true`() {
val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url")
val status = buildStatus(currency = coin, isError = true)
val result = sut.convert(status) as CurrencyIconState.CoinIcon
assertThat(result.isGrayscale).isTrue()
}
@Test
fun `GIVEN converter not available WHEN convert coin THEN isGrayscale is true`() {
val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url")
val status = buildStatus(currency = coin, isError = false)
val result = sutUnavailable.convert(status) as CurrencyIconState.CoinIcon
assertThat(result.isGrayscale).isTrue()
}
@Test
fun `GIVEN forceGrayscale flag WHEN convertCustom coin THEN isGrayscale is true`() {
val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url")
val status = buildStatus(currency = coin, isError = false)
val result = sut.convertCustom(
value = status,
forceGrayscale = true,
showCustomTokenBadge = true,
) as CurrencyIconState.CoinIcon
assertThat(result.isGrayscale).isTrue()
}
// endregion
// region getIconStateForToken — branches
@Test
fun `GIVEN custom token without iconUrl WHEN convert THEN return CustomTokenIcon`() {
val token = buildToken(
isTestnet = false,
isCustom = true,
iconUrl = null,
contractAddress = USDT_CONTRACT,
)
val status = buildStatus(currency = token, isError = false)
val result = sut.convert(status) as CurrencyIconState.CustomTokenIcon
assertThat(result.topBadgeIconResId).isEqualTo(NETWORK_ICON_RES_ID)
assertThat(result.isGrayscale).isFalse()
assertThat(result.shouldShowCustomBadge).isTrue()
}
@Test
fun `GIVEN custom token with iconUrl WHEN convert THEN return TokenIcon with custom badge`() {
val token = buildToken(
isTestnet = false,
isCustom = true,
iconUrl = "https://example.com/usdt.png",
contractAddress = USDT_CONTRACT,
)
val status = buildStatus(currency = token, isError = false)
val result = sut.convert(status) as CurrencyIconState.TokenIcon
assertThat(result.url).isEqualTo("https://example.com/usdt.png")
assertThat(result.shouldShowCustomBadge).isTrue()
}
// endregion
// region helpers
private fun buildCoin(
isTestnet: Boolean,
isCustom: Boolean,
iconUrl: String?,
): CryptoCurrency.Coin {
val network: Network = mockk { every { this@mockk.isTestnet } returns isTestnet }
val coin: CryptoCurrency.Coin = mockk()
every { coin.network } returns network
every { coin.iconUrl } returns iconUrl
every { coin.isCustom } returns isCustom
every { coin.networkIconResId } returns NETWORK_ICON_RES_ID
return coin
}
private fun buildToken(
isTestnet: Boolean,
isCustom: Boolean,
iconUrl: String?,
contractAddress: String,
): CryptoCurrency.Token {
val network: Network = mockk { every { this@mockk.isTestnet } returns isTestnet }
val token: CryptoCurrency.Token = mockk()
every { token.network } returns network
every { token.iconUrl } returns iconUrl
every { token.isCustom } returns isCustom
every { token.contractAddress } returns contractAddress
every { token.networkIconResId } returns NETWORK_ICON_RES_ID
return token
}
private fun buildStatus(currency: CryptoCurrency, isError: Boolean): CryptoCurrencyStatus {
val value: CryptoCurrencyStatus.Value = mockk { every { this@mockk.isError } returns isError }
return CryptoCurrencyStatus(currency = currency, value = value)
}
// endregion
private companion object {
const val NETWORK_ICON_RES_ID = 1234
const val USDT_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
}
}

View file

@ -96,6 +96,7 @@ internal class BlockchainIconsTest {
Blockchain.Optimism, Blockchain.OptimismTestnet -> R.drawable.img_optimism_22
Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> R.drawable.img_pepecoin_22
Blockchain.Plasma, Blockchain.PlasmaTestnet -> R.drawable.img_plasma_22
Blockchain.Adi, Blockchain.AdiTestnet -> R.drawable.img_adi_22
Blockchain.Playa3ull -> R.drawable.img_playa3ull_22
Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.drawable.img_polkadot_22
Blockchain.Polygon, Blockchain.PolygonTestnet -> R.drawable.img_polygon_22
@ -218,6 +219,7 @@ internal class BlockchainIconsTest {
Blockchain.Optimism, Blockchain.OptimismTestnet -> R.drawable.ic_optimism_22
Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> R.drawable.ic_pepecoin_22
Blockchain.Plasma, Blockchain.PlasmaTestnet -> R.drawable.ic_plasma_22
Blockchain.Adi, Blockchain.AdiTestnet -> R.drawable.ic_adi_22
Blockchain.Playa3ull -> R.drawable.ic_playa3ull_22
Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.drawable.ic_polkadot_16
Blockchain.Polygon, Blockchain.PolygonTestnet -> R.drawable.ic_polygon_22

View file

@ -0,0 +1,244 @@
package com.tangem.common.ui.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
internal class SwapRateDirectionResolverTest {
@Test
fun `GIVEN stable usdt and stable usdc WHEN resolve THEN base is usdt`() {
val usdt = stable(symbol = "USDT")
val usdc = stable(symbol = "USDC")
val result = SwapRateDirectionResolver.resolve(from = usdt, to = usdc)
assertThat(result).isEqualTo(SwapRateDirection(base = usdt, quote = usdc))
}
@Test
fun `GIVEN stable dai and stable usdt WHEN resolve THEN base is usdt`() {
val dai = stable(symbol = "DAI")
val usdt = stable(symbol = "USDT")
val result = SwapRateDirectionResolver.resolve(from = dai, to = usdt)
assertThat(result).isEqualTo(SwapRateDirection(base = usdt, quote = dai))
}
@Test
fun `GIVEN stable usdd and stable usdc WHEN resolve THEN base is usdc`() {
val usdd = stable(symbol = "USDD")
val usdc = stable(symbol = "USDC")
val result = SwapRateDirectionResolver.resolve(from = usdd, to = usdc)
assertThat(result).isEqualTo(SwapRateDirection(base = usdc, quote = usdd))
}
@Test
fun `GIVEN coin and stable WHEN resolve THEN base is coin`() {
val sol = coin(symbol = "SOL")
val usdt = stable(symbol = "USDT")
val result = SwapRateDirectionResolver.resolve(from = sol, to = usdt)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = usdt))
}
@Test
fun `GIVEN stable and coin WHEN resolve THEN base is coin`() {
val usdt = stable(symbol = "USDT")
val sol = coin(symbol = "SOL")
val result = SwapRateDirectionResolver.resolve(from = usdt, to = sol)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = usdt))
}
@Test
fun `GIVEN coin and btc WHEN resolve THEN base is coin and quote is btc`() {
val sol = coin(symbol = "SOL")
val btc = coin(symbol = "BTC")
val result = SwapRateDirectionResolver.resolve(from = sol, to = btc)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = btc))
}
@Test
fun `GIVEN btc and coin WHEN resolve THEN base is coin and quote is btc`() {
val btc = coin(symbol = "BTC")
val trx = coin(symbol = "TRX")
val result = SwapRateDirectionResolver.resolve(from = btc, to = trx)
assertThat(result).isEqualTo(SwapRateDirection(base = trx, quote = btc))
}
@Test
fun `GIVEN coin and eth WHEN resolve THEN base is coin and quote is eth`() {
val sol = coin(symbol = "SOL")
val eth = coin(symbol = "ETH")
val result = SwapRateDirectionResolver.resolve(from = sol, to = eth)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = eth))
}
@Test
fun `GIVEN eth and coin WHEN resolve THEN base is coin and quote is eth`() {
val eth = coin(symbol = "ETH")
val sol = coin(symbol = "SOL")
val result = SwapRateDirectionResolver.resolve(from = eth, to = sol)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = eth))
}
@Test
fun `GIVEN btc and eth WHEN resolve THEN base is eth and quote is btc`() {
val btc = coin(symbol = "BTC")
val eth = coin(symbol = "ETH")
val result = SwapRateDirectionResolver.resolve(from = btc, to = eth)
assertThat(result).isEqualTo(SwapRateDirection(base = eth, quote = btc))
}
@Test
fun `GIVEN eth and btc WHEN resolve THEN base is eth and quote is btc`() {
val eth = coin(symbol = "ETH")
val btc = coin(symbol = "BTC")
val result = SwapRateDirectionResolver.resolve(from = eth, to = btc)
assertThat(result).isEqualTo(SwapRateDirection(base = eth, quote = btc))
}
@Test
fun `GIVEN two non-major coins WHEN resolve THEN base is to and quote is from`() {
val sol = coin(symbol = "SOL")
val trx = coin(symbol = "TRX")
val result = SwapRateDirectionResolver.resolve(from = sol, to = trx)
assertThat(result).isEqualTo(SwapRateDirection(base = trx, quote = sol))
}
@Test
fun `GIVEN token symbol matching priority list but not Token type WHEN resolve THEN treated as coin`() {
// Edge: a CryptoCurrency.Coin whose symbol coincidentally equals a stable symbol must NOT
// be treated as stable — the type check is type-aware now.
val usdtLikeCoin = coin(symbol = "USDT")
val usdt = stable(symbol = "USDT")
val result = SwapRateDirectionResolver.resolve(from = usdtLikeCoin, to = usdt)
// usdt is stable, usdtLikeCoin is a Coin → Coin↔Stable rule, base = coin
assertThat(result).isEqualTo(SwapRateDirection(base = usdtLikeCoin, quote = usdt))
}
@Test
fun `GIVEN non-stable token and coin WHEN resolve THEN base is to and quote is from`() {
// Non-stable Token (e.g., LINK) is neither Stable nor Coin → falls into default branch.
val link = token(symbol = "LINK")
val eth = coin(symbol = "ETH")
val result = SwapRateDirectionResolver.resolve(from = link, to = eth)
assertThat(result).isEqualTo(SwapRateDirection(base = eth, quote = link))
}
@Test
fun `GIVEN two non-stable tokens WHEN resolve THEN base is to and quote is from`() {
val link = token(symbol = "LINK")
val aave = token(symbol = "AAVE")
val result = SwapRateDirectionResolver.resolve(from = link, to = aave)
assertThat(result).isEqualTo(SwapRateDirection(base = aave, quote = link))
}
@Test
fun `GIVEN lowercase usdt and lowercase usdc WHEN resolve THEN base is usdt`() {
val usdt = stable(symbol = "usdt")
val usdc = stable(symbol = "usdc")
val result = SwapRateDirectionResolver.resolve(from = usdt, to = usdc)
assertThat(result).isEqualTo(SwapRateDirection(base = usdt, quote = usdc))
}
@Test
fun `GIVEN stable usdt and bridged usdc_e WHEN resolve THEN base is usdt`() {
val usdt = stable(symbol = "USDT")
val usdcE = stable(symbol = "USDC.E")
val result = SwapRateDirectionResolver.resolve(from = usdt, to = usdcE)
assertThat(result).isEqualTo(SwapRateDirection(base = usdt, quote = usdcE))
}
@Test
fun `GIVEN bridged usdc_e and stable usdt WHEN resolve THEN base is usdt`() {
val usdcE = stable(symbol = "USDC.E")
val usdt = stable(symbol = "USDT")
val result = SwapRateDirectionResolver.resolve(from = usdcE, to = usdt)
assertThat(result).isEqualTo(SwapRateDirection(base = usdt, quote = usdcE))
}
@Test
fun `GIVEN coin sol and bridged usdc_e WHEN resolve THEN base is coin`() {
val sol = coin(symbol = "SOL")
val usdcE = stable(symbol = "USDC.E")
val result = SwapRateDirectionResolver.resolve(from = sol, to = usdcE)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = usdcE))
}
@Test
fun `GIVEN bridged usdc_e and coin sol WHEN resolve THEN base is coin`() {
val usdcE = stable(symbol = "USDC.E")
val sol = coin(symbol = "SOL")
val result = SwapRateDirectionResolver.resolve(from = usdcE, to = sol)
assertThat(result).isEqualTo(SwapRateDirection(base = sol, quote = usdcE))
}
@Test
fun `GIVEN lowercase bridged usdt_e and stable usdc WHEN resolve THEN base is usdt`() {
val usdtE = stable(symbol = "usdt.e")
val usdc = stable(symbol = "USDC")
val result = SwapRateDirectionResolver.resolve(from = usdtE, to = usdc)
assertThat(result).isEqualTo(SwapRateDirection(base = usdtE, quote = usdc))
}
@Test
fun `GIVEN bridged dai_e and bridged usdc_e WHEN resolve THEN base is usdc`() {
val daiE = stable(symbol = "DAI.E")
val usdcE = stable(symbol = "USDC.E")
val result = SwapRateDirectionResolver.resolve(from = daiE, to = usdcE)
assertThat(result).isEqualTo(SwapRateDirection(base = usdcE, quote = daiE))
}
private fun coin(symbol: String): CryptoCurrency = mockk<CryptoCurrency.Coin> {
every { this@mockk.symbol } returns symbol
}
private fun token(symbol: String): CryptoCurrency = mockk<CryptoCurrency.Token> {
every { this@mockk.symbol } returns symbol
}
private fun stable(symbol: String): CryptoCurrency = token(symbol)
}

View file

@ -0,0 +1,161 @@
package com.tangem.common.ui.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.StringsSigns
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.util.Locale
internal class SwapRateFormatterTest {
private var originalLocale: Locale = Locale.getDefault()
@BeforeEach
fun setUp() {
originalLocale = Locale.getDefault()
Locale.setDefault(Locale.US)
}
@AfterEach
fun tearDown() {
Locale.setDefault(originalLocale)
}
@Test
fun `GIVEN coin to stable swap WHEN formatRate THEN base is coin`() {
val eth = coin(symbol = "ETH", decimals = 18)
val usdt = stable(symbol = "USDT", decimals = 6)
val result = SwapRateFormatter.formatRate(
from = eth,
to = usdt,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("3000"),
)
result.assertOrder(base = "ETH", quote = "USDT")
assertThat(result).contains("3,000")
}
@Test
fun `GIVEN stable to coin swap WHEN formatRate THEN base is coin`() {
val usdt = stable(symbol = "USDT", decimals = 6)
val eth = coin(symbol = "ETH", decimals = 18)
val result = SwapRateFormatter.formatRate(
from = usdt,
to = eth,
fromAmount = BigDecimal("3000"),
toAmount = BigDecimal.ONE,
)
result.assertOrder(base = "ETH", quote = "USDT")
assertThat(result).contains("3,000")
}
@Test
fun `GIVEN btc to other coin swap WHEN formatRate THEN base is other coin`() {
val btc = coin(symbol = "BTC", decimals = 8)
val sol = coin(symbol = "SOL", decimals = 8)
val result = SwapRateFormatter.formatRate(
from = btc,
to = sol,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("20"),
)
result.assertOrder(base = "SOL", quote = "BTC")
assertThat(result).contains("0.05")
}
@Test
fun `GIVEN btc and eth swap WHEN formatRate THEN base is eth`() {
val btc = coin(symbol = "BTC", decimals = 8)
val eth = coin(symbol = "ETH", decimals = 18)
val result = SwapRateFormatter.formatRate(
from = btc,
to = eth,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("18"),
)
result.assertOrder(base = "ETH", quote = "BTC")
assertThat(result).contains("0.05555")
}
@Test
fun `GIVEN two stables swap WHEN formatRate THEN base is higher ranked`() {
val usdc = stable(symbol = "USDC", decimals = 6)
val dai = stable(symbol = "DAI", decimals = 18)
val result = SwapRateFormatter.formatRate(
from = dai,
to = usdc,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("0.999"),
)
result.assertOrder(base = "USDC", quote = "DAI")
assertThat(result).contains("1.001")
}
@Test
fun `GIVEN two non-major coins swap WHEN formatRate THEN base is to currency`() {
val sol = coin(symbol = "SOL", decimals = 8)
val trx = coin(symbol = "TRX", decimals = 6)
val result = SwapRateFormatter.formatRate(
from = sol,
to = trx,
fromAmount = BigDecimal.ONE,
toAmount = BigDecimal("100"),
)
result.assertOrder(base = "TRX", quote = "SOL")
assertThat(result).contains("0.01")
}
@Test
fun `GIVEN zero from amount WHEN formatRate THEN rate is zero`() {
val eth = coin(symbol = "ETH", decimals = 18)
val usdt = stable(symbol = "USDT", decimals = 6)
val result = SwapRateFormatter.formatRate(
from = eth,
to = usdt,
fromAmount = BigDecimal.ZERO,
toAmount = BigDecimal.ZERO,
)
result.assertOrder(base = "ETH", quote = "USDT")
assertThat(result).contains("0.00")
}
private fun String.assertOrder(base: String, quote: String) {
val baseIndex = indexOf(base)
val quoteIndex = lastIndexOf(quote)
assertThat(baseIndex).isAtLeast(0)
assertThat(quoteIndex).isGreaterThan(baseIndex)
assertThat(this).contains(StringsSigns.APPROXIMATE)
val approximateIndex = indexOf(StringsSigns.APPROXIMATE)
assertThat(approximateIndex).isGreaterThan(baseIndex)
assertThat(quoteIndex).isGreaterThan(approximateIndex)
}
private fun coin(symbol: String, decimals: Int): CryptoCurrency = mockk<CryptoCurrency.Coin> {
every { this@mockk.symbol } returns symbol
every { this@mockk.decimals } returns decimals
}
private fun stable(symbol: String, decimals: Int): CryptoCurrency = mockk<CryptoCurrency.Token> {
every { this@mockk.symbol } returns symbol
every { this@mockk.decimals } returns decimals
}
}