Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-07 13:02:35 +03:00
parent 2b9d0d9dcb
commit a354c649b2
26 changed files with 3369 additions and 204 deletions

View file

@ -0,0 +1,74 @@
package com.tangem.core.ui.components.transactions
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.res.TangemTheme
internal const val INLINE_IMAGE_PLACEHOLDER = "%image%"
private const val INLINE_IMAGE_ID = "inline_subtitle_icon"
/**
* Single-line caption with an inline icon between two text parts.
*
* Use a string resource of the shape `"prefix %%image%% %1\$s"` (escaped `%` so the marker
* survives Lokalise round-trips), pre-format it via `stringResourceSafe`, and pass the result
* here [INLINE_IMAGE_PLACEHOLDER] is replaced with an [InlineTextContent] driven by [icon].
*/
@Composable
internal fun InlineImageSubtitle(
template: String,
color: Color,
modifier: Modifier = Modifier,
afterIconColor: Color = color,
iconSize: Dp = TangemTheme.dimens2.x4,
icon: @Composable () -> Unit,
) {
val parts = remember(template) {
val split = template.split(INLINE_IMAGE_PLACEHOLDER, limit = 2)
if (split.size == 2) split[0] to split[1] else template to ""
}
val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() }
val inlineContent = remember(iconSizeSp) {
mapOf(
INLINE_IMAGE_ID to InlineTextContent(
placeholder = Placeholder(
width = iconSizeSp,
height = iconSizeSp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
children = { icon() },
),
)
}
val annotated = remember(parts, afterIconColor) {
buildAnnotatedString {
append(parts.first)
appendInlineContent(INLINE_IMAGE_ID, INLINE_IMAGE_PLACEHOLDER)
withStyle(SpanStyle(color = afterIconColor)) {
append(parts.second)
}
}
}
Text(
text = annotated,
inlineContent = inlineContent,
color = color,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}

View file

@ -48,6 +48,10 @@ import java.util.UUID
*
[REDACTED_AUTHOR]
*/
@Deprecated(
message = "Legacy. Use TransactionItem for redesigned screens",
level = DeprecationLevel.WARNING,
)
@Composable
@Suppress("LongMethod")
fun Transaction(state: TransactionState, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
@ -330,6 +334,7 @@ private fun TransactionState.isGoneIf(goneCondition: TransactionState.Content.()
return if ((this as? TransactionState.Content)?.goneCondition() == true) Visibility.Gone else Visibility.Visible
}
@Suppress("DEPRECATION")
@Preview(showBackground = true, widthDp = 368)
@Preview(showBackground = true, widthDp = 368, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -0,0 +1,443 @@
package com.tangem.core.ui.components.transactions
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.ds.image.TangemDeviceIcon
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.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
when (state) {
is TransactionItemUM.Content -> ContentItem(
state = state,
isBalanceHidden = isBalanceHidden,
modifier = modifier,
)
is TransactionItemUM.Pill -> TransactionStatusPill(
state = state,
isBalanceHidden = isBalanceHidden,
modifier = modifier,
)
is TransactionItemUM.Loading,
is TransactionItemUM.Locked,
-> Unit
}
}
@Composable
private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
val rowModifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors2.surface.level1)
.clickable(onClick = state.onClick)
TangemRowContainer(
modifier = rowModifier,
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens2.x4,
vertical = TangemTheme.dimens2.x3,
),
) {
StatusCircle(
iconRes = state.iconRes,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x3)
.size(TangemTheme.dimens2.x10),
)
TitleText(
title = state.title,
status = state.status,
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
)
SubtitleText(
subtitle = state.subtitle,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.START_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5),
)
AmountText(
amount = state.amount,
status = state.status,
isBalanceHidden = isBalanceHidden,
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP),
)
CurrencyText(
symbol = state.currencySymbol,
modifier = Modifier
.layoutId(TangemRowLayoutId.END_BOTTOM)
.padding(top = TangemTheme.dimens2.x0_5),
)
}
}
// region Status circle
@Composable
private fun StatusCircle(iconRes: Int, status: Status, modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = status.backgroundColor,
shape = CircleShape,
),
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = status.iconTint,
modifier = Modifier
.size(TangemTheme.dimens2.x5)
.align(Alignment.Center),
)
}
}
private val Status.backgroundColor: Color
@Composable get() = when (this) {
is Status.Confirmed -> TangemTheme.colors2.markers.backgroundTintedGray
is Status.Unconfirmed -> TangemTheme.colors2.markers.backgroundTintedBlue
is Status.Failed -> TangemTheme.colors2.markers.backgroundTintedRed
}
private val Status.iconTint: Color
@Composable get() = when (this) {
is Status.Confirmed -> TangemTheme.colors2.fill.neutral.primary
is Status.Unconfirmed -> TangemTheme.colors2.markers.iconBlue
is Status.Failed -> TangemTheme.colors2.markers.iconRed
}
// endregion
// region Title / Subtitle
@Composable
private fun TitleText(title: TextReference, status: Status, modifier: Modifier = Modifier) {
Text(
text = title.resolveReference(),
color = status.titleColor,
style = TangemTheme.typography2.bodyMedium16,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
private val Status.titleColor: Color
@Composable get() = when (this) {
is Status.Confirmed -> TangemTheme.colors2.text.neutral.primary
is Status.Unconfirmed -> TangemTheme.colors2.text.status.accent
is Status.Failed -> TangemTheme.colors2.text.status.warning
}
@Suppress("LongMethod")
@Composable
private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Modifier = Modifier) {
val textStyle = TangemTheme.typography2.captionMedium12
val tertiary = TangemTheme.colors2.text.neutral.tertiary
val primary = TangemTheme.colors2.text.neutral.primary
val isFailed = status is Status.Failed
when (subtitle) {
is ContentSubtitle.Plain -> Text(
text = subtitle.text.resolveReference(),
color = tertiary,
style = textStyle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
is ContentSubtitle.ExternalAddress -> InlineImageSubtitle(
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.briefAddress),
color = tertiary,
modifier = modifier,
) {
IdentIcon(
address = subtitle.rawAddress,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
)
}
is ContentSubtitle.OwnAccount -> InlineImageSubtitle(
template = stringResourceSafe(
subtitle.direction.templateResId(),
subtitle.accountName.resolveReference(),
),
color = tertiary,
afterIconColor = if (isFailed) tertiary else primary,
modifier = modifier,
) {
val backgroundColor = if (isFailed) {
TangemTheme.colors2.graphic.neutral.quaternary
} else {
subtitle.iconBackgroundColor
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(TangemTheme.dimens2.x1))
.background(backgroundColor),
) {
Icon(
imageVector = ImageVector.vectorResource(id = subtitle.iconResId),
contentDescription = null,
tint = TangemTheme.colors.text.constantWhite,
modifier = Modifier.size(TangemTheme.dimens2.x2_5),
)
}
}
is ContentSubtitle.OwnWallet -> InlineImageSubtitle(
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.walletName),
color = tertiary,
afterIconColor = primary,
modifier = modifier,
) {
TangemDeviceIcon(
state = subtitle.deviceIconUM,
modifier = Modifier.fillMaxSize(),
)
}
}
}
private fun ContentSubtitle.Direction.templateResId(): Int = when (this) {
ContentSubtitle.Direction.TO -> R.string.transaction_history_to_inline_address
ContentSubtitle.Direction.FROM -> R.string.transaction_history_from_inline_address
}
// endregion
// region Amount
@Composable
private fun AmountText(amount: String, status: Status, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
val display = if (status is Status.Failed) amount.stripLeadingSign() else amount
Text(
text = display.orMaskWithStars(isBalanceHidden),
color = if (status is Status.Confirmed) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors2.text.neutral.tertiary
},
textDecoration = if (status is Status.Failed) TextDecoration.LineThrough else null,
style = TangemTheme.typography2.bodyMedium16,
maxLines = 1,
modifier = modifier,
)
}
@Composable
private fun CurrencyText(symbol: String, modifier: Modifier = Modifier) {
Text(
text = symbol,
color = TangemTheme.colors2.text.neutral.tertiary,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
modifier = modifier,
)
}
private fun String.stripLeadingSign(): String = when {
startsWith('+') || startsWith('-') || startsWith('') -> drop(1).trim()
else -> this
}
// endregion
// region Preview
@Suppress("LongParameterList")
private fun previewContent(
txHash: String,
iconRes: Int,
direction: Direction,
status: Status,
title: String,
subtitle: String,
amount: String,
currencySymbol: String = "USDT",
): TransactionItemUM.Content = TransactionItemUM.Content(
txHash = txHash,
amount = amount,
currencySymbol = currencySymbol,
time = "",
status = status,
direction = direction,
onClick = {},
iconRes = iconRes,
title = stringReference(title),
subtitle = ContentSubtitle.Plain(stringReference(subtitle)),
timestamp = 0L,
)
@Composable
private fun PreviewColumn(items: List<TransactionItemUM>) {
Column(
modifier = Modifier
.background(TangemTheme.colors2.surface.level1)
.padding(TangemTheme.dimens2.x2),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
items.forEach { TransactionItem(state = it, isBalanceHidden = false) }
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TransactionItem_Receive() {
TangemThemePreviewRedesign {
PreviewColumn(
items = listOf(
previewContent(
txHash = "rcv-c",
iconRes = R.drawable.ic_arrow_down_24,
direction = Direction.INCOMING,
status = Status.Confirmed,
title = "Received",
subtitle = "from: 33BdfS...ga2B",
amount = "+350.00",
),
previewContent(
txHash = "rcv-u",
iconRes = R.drawable.ic_arrow_down_24,
direction = Direction.INCOMING,
status = Status.Unconfirmed,
title = "Receiving",
subtitle = "from: 33BdfS...ga2B",
amount = "+350.00",
),
previewContent(
txHash = "rcv-f",
iconRes = R.drawable.ic_close_24,
direction = Direction.INCOMING,
status = Status.Failed,
title = "Receiving failed",
subtitle = "from: 33BdfS...ga2B",
amount = "350.00",
),
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TransactionItem_Send() {
TangemThemePreviewRedesign {
PreviewColumn(
items = listOf(
previewContent(
txHash = "snd-c",
iconRes = R.drawable.ic_arrow_up_24,
direction = Direction.OUTGOING,
status = Status.Confirmed,
title = "Sent",
subtitle = "to: 33BdfS...ga2B",
amount = "-350.31",
),
previewContent(
txHash = "snd-u",
iconRes = R.drawable.ic_arrow_up_24,
direction = Direction.OUTGOING,
status = Status.Unconfirmed,
title = "Sending",
subtitle = "to: 33BdfS...ga2B",
amount = "+350.31",
),
previewContent(
txHash = "snd-f",
iconRes = R.drawable.ic_close_24,
direction = Direction.OUTGOING,
status = Status.Failed,
title = "Sending failed",
subtitle = "to: 33BdfS...ga2B",
amount = "350.31",
),
),
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TransactionItem_Swap() {
TangemThemePreviewRedesign {
PreviewColumn(
items = listOf(
previewContent(
txHash = "swp-c",
iconRes = R.drawable.ic_exchange_vertical_24,
direction = Direction.INCOMING,
status = Status.Confirmed,
title = "Swapped",
subtitle = "to: POL",
amount = "+350.00",
),
previewContent(
txHash = "swp-u",
iconRes = R.drawable.ic_exchange_vertical_24,
direction = Direction.INCOMING,
status = Status.Unconfirmed,
title = "Swapping",
subtitle = "to: POL",
amount = "+350.00",
),
previewContent(
txHash = "swp-f",
iconRes = R.drawable.ic_close_24,
direction = Direction.INCOMING,
status = Status.Failed,
title = "Swapping failed",
subtitle = "to: POL",
amount = "350.00",
),
),
)
}
}
// endregion

View file

@ -0,0 +1,314 @@
package com.tangem.core.ui.components.transactions
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.ui.unit.dp
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.core.ui.R
import com.tangem.core.ui.components.icons.identicon.IdentIcon
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.PillKind
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.PillSubtitle
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
internal fun TransactionStatusPill(
state: TransactionItemUM.Pill,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors2.surface.level1)
.clickable(onClick = state.onClick)
.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2),
horizontalArrangement = Arrangement.Center,
) {
Pill(state = state, isBalanceHidden = isBalanceHidden)
}
}
@Composable
private fun Pill(state: TransactionItemUM.Pill, isBalanceHidden: Boolean) {
val labelColor = state.status.labelColor()
val secondaryColor = state.status.secondaryColor()
Row(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(TangemTheme.colors2.tabs.backgroundSecondary)
.padding(horizontal = TangemTheme.dimens2.x2, vertical = TangemTheme.dimens2.x1),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
LeadingIcon(kind = state.kind, status = state.status)
if (state.status is Status.Failed && state.amount != null) {
Text(
text = stringResourceSafe(R.string.common_action_failed, state.failedBody(isBalanceHidden)),
color = labelColor,
style = TangemTheme.typography2.captionMedium12,
)
} else {
Text(
text = state.label.resolveReference(),
color = labelColor,
style = TangemTheme.typography2.captionMedium12,
)
if (state.amount != null) {
Text(
text = state.amount.orMaskWithStars(isBalanceHidden),
color = secondaryColor,
style = TangemTheme.typography2.captionMedium12,
)
state.currencySymbol?.let { symbol ->
Text(
text = symbol,
color = secondaryColor,
style = TangemTheme.typography2.captionMedium12,
)
}
}
}
val subtitle = state.subtitle
if (subtitle is PillSubtitle.Address && state.status !is Status.Failed) {
InlineImageSubtitle(
template = stringResourceSafe(
R.string.transaction_history_to_inline_address,
subtitle.briefAddress,
),
color = secondaryColor,
afterIconColor = labelColor,
) {
IdentIcon(
address = subtitle.rawAddress,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
)
}
}
}
}
@Composable
private fun LeadingIcon(kind: PillKind, status: Status) {
if (status is Status.Unconfirmed) {
CircularProgressIndicator(
strokeWidth = 1.5.dp,
color = TangemTheme.colors2.markers.iconBlue,
modifier = Modifier.size(TangemTheme.dimens2.x4),
)
return
}
val iconRes = when (status) {
is Status.Failed -> R.drawable.ic_close_24
is Status.Confirmed -> when (kind) {
PillKind.STAKING -> R.drawable.ic_transaction_history_staking_24
PillKind.YIELD_MODE -> R.drawable.ic_yield_mode_16
PillKind.APPROVE -> null
}
is Status.Unconfirmed -> null
} ?: return
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = status.iconTint(),
modifier = Modifier.size(TangemTheme.dimens2.x4),
)
}
@Composable
private fun Status.labelColor(): Color = when (this) {
is Status.Confirmed -> TangemTheme.colors2.text.neutral.secondary
is Status.Unconfirmed -> TangemTheme.colors2.text.status.accent
is Status.Failed -> TangemTheme.colors2.text.status.warning
}
@Composable
private fun Status.secondaryColor(): Color = when (this) {
is Status.Confirmed -> TangemTheme.colors2.text.neutral.primary
is Status.Unconfirmed -> TangemTheme.colors2.text.status.accent
is Status.Failed -> TangemTheme.colors2.text.status.warning
}
@Composable
private fun Status.iconTint(): Color = when (this) {
is Status.Confirmed -> TangemTheme.colors2.fill.neutral.primary
is Status.Unconfirmed -> TangemTheme.colors2.markers.iconBlue
is Status.Failed -> TangemTheme.colors2.markers.iconRed
}
@Composable
private fun TransactionItemUM.Pill.failedBody(isBalanceHidden: Boolean): String = buildString {
append(label.resolveReference())
amount?.let { value ->
append(' ')
append(value.orMaskWithStars(isBalanceHidden))
}
currencySymbol?.let { symbol ->
append(' ')
append(symbol)
}
}
// region Preview
private fun previewPill(
txHash: String,
kind: PillKind,
status: Status,
label: String,
amount: String? = null,
currencySymbol: String? = null,
subtitle: PillSubtitle? = null,
): TransactionItemUM.Pill = TransactionItemUM.Pill(
txHash = txHash,
kind = kind,
status = status,
label = stringReference(label),
amount = amount,
currencySymbol = currencySymbol,
subtitle = subtitle,
timestamp = 0L,
onClick = {},
)
@Composable
private fun PillPreviewColumn(items: List<TransactionItemUM.Pill>) {
Column(
modifier = Modifier
.background(TangemTheme.colors2.surface.level1)
.padding(vertical = TangemTheme.dimens2.x2),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
items.forEach { TransactionStatusPill(state = it, isBalanceHidden = false) }
}
}
@Suppress("NamedArguments")
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TransactionStatusPill_Staking() {
TangemThemePreviewRedesign {
PillPreviewColumn(
items = listOf(
previewPill("stk-c", PillKind.STAKING, Status.Confirmed, "Staked", "950.43", "TRX"),
previewPill("stk-u", PillKind.STAKING, Status.Unconfirmed, "Staking", "1,000.00", "TRX"),
previewPill("stk-f", PillKind.STAKING, Status.Failed, "Staking failed"),
previewPill("ust-c", PillKind.STAKING, Status.Confirmed, "Unstaked", "950.43", "TRX"),
previewPill("ust-u", PillKind.STAKING, Status.Unconfirmed, "Unstaking", "1,000.00", "TRX"),
previewPill("ust-f", PillKind.STAKING, Status.Failed, "Unstaking failed"),
previewPill("rst-c", PillKind.STAKING, Status.Confirmed, "Rewards restaked", "20.15", "TRX"),
previewPill("rst-u", PillKind.STAKING, Status.Unconfirmed, "Rewards restaking", "20.15", "TRX"),
previewPill("rst-f", PillKind.STAKING, Status.Failed, "Rewards restaking failed"),
previewPill("wd-c", PillKind.STAKING, Status.Confirmed, "Withdraw"),
previewPill("wd-u", PillKind.STAKING, Status.Unconfirmed, "Withdrawing"),
previewPill("wd-f", PillKind.STAKING, Status.Failed, "Withdraw failed"),
previewPill("vt-c", PillKind.STAKING, Status.Confirmed, "Vote"),
previewPill("vt-u", PillKind.STAKING, Status.Unconfirmed, "Voting"),
previewPill("vt-f", PillKind.STAKING, Status.Failed, "Vote failed"),
),
)
}
}
@Suppress("NamedArguments")
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TransactionStatusPill_YieldMode() {
TangemThemePreviewRedesign {
PillPreviewColumn(
items = listOf(
previewPill("yon-c", PillKind.YIELD_MODE, Status.Confirmed, "Yield mode Enabled"),
previewPill("yon-u", PillKind.YIELD_MODE, Status.Unconfirmed, "Activating Yield mode"),
previewPill("yon-f", PillKind.YIELD_MODE, Status.Failed, "Yield mode failed"),
previewPill("yof-c", PillKind.YIELD_MODE, Status.Confirmed, "Yield mode disabled"),
previewPill("yof-u", PillKind.YIELD_MODE, Status.Unconfirmed, "Disabling Yield mode"),
previewPill("yof-f", PillKind.YIELD_MODE, Status.Failed, "Disabling Yield mode failed"),
),
)
}
}
@Suppress("NamedArguments")
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TransactionStatusPill_Approve() {
TangemThemePreviewRedesign {
PillPreviewColumn(
items = listOf(
// dApp variant — no subtitle
previewPill("apv-c", PillKind.APPROVE, Status.Confirmed, "Approved", "2,350.00", "USDT"),
previewPill("apv-u", PillKind.APPROVE, Status.Unconfirmed, "Approving", "2,350.00", "USDT"),
previewPill("apv-f", PillKind.APPROVE, Status.Failed, "Approving", "2,350.00", "USDT"),
// Address variant — with subtitle
previewPill(
txHash = "apa-c",
kind = PillKind.APPROVE,
status = Status.Confirmed,
label = "Approved",
amount = "2,350.00",
currencySymbol = "USDT",
subtitle = PillSubtitle.Address(
rawAddress = "33BdfSXXXXXXXXXXXXXXXXXXXXXXga2B",
briefAddress = "33BdfS...ga2B",
),
),
previewPill(
txHash = "apa-u",
kind = PillKind.APPROVE,
status = Status.Unconfirmed,
label = "Approving",
amount = "2,350.00",
currencySymbol = "USDT",
subtitle = PillSubtitle.Address(
rawAddress = "33BdfSXXXXXXXXXXXXXXXXXXXXXXga2B",
briefAddress = "33BdfS...ga2B",
),
),
previewPill(
txHash = "apa-f",
kind = PillKind.APPROVE,
status = Status.Failed,
label = "Approving",
amount = "2,350.00",
currencySymbol = "USDT",
subtitle = PillSubtitle.Address(
rawAddress = "33BdfSXXXXXXXXXXXXXXXXXXXXXXga2B",
briefAddress = "33BdfS...ga2B",
),
),
),
)
}
}
// endregion

View file

@ -0,0 +1,37 @@
package com.tangem.core.ui.components.transactions
import android.content.res.Configuration
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
fun TxHistoryDateHeader(title: String, modifier: Modifier = Modifier) {
Text(
text = title,
color = TangemTheme.colors2.text.neutral.primary,
style = TangemTheme.typography2.bodyMedium16,
modifier = modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens2.x4,
end = TangemTheme.dimens2.x4,
top = TangemTheme.dimens2.x6,
bottom = TangemTheme.dimens2.x3,
),
)
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_TxHistoryDateHeader() {
TangemThemePreviewRedesign {
TxHistoryDateHeader(title = "Today")
}
}

View file

@ -0,0 +1,133 @@
package com.tangem.core.ui.components.transactions.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
/**
* UI model for the redesigned transaction list item ([REDACTED_TASK_KEY]).
*
* Mirrors the field set of the legacy [TransactionState] but splits the formatted amount string
* into a numeric [Content.amount] (with sign) and a separate [Content.currencySymbol], so the
* redesigned `TransactionItem` composable can render them on independent lines without parsing.
*/
@Immutable
sealed interface TransactionItemUM {
/** Transaction hash */
val txHash: String
/**
* Content state.
*
* @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded
* @property currencySymbol currency symbol shown alongside [amount], e.g. "BTC", "USDT"
*/
data class Content(
override val txHash: String,
val amount: String,
val currencySymbol: String,
val time: String,
val status: Status,
val direction: Direction,
val onClick: () -> Unit,
@DrawableRes val iconRes: Int,
val title: TextReference,
val subtitle: ContentSubtitle,
val timestamp: Long,
) : TransactionItemUM {
@Immutable
sealed class Status {
data object Failed : Status()
data object Confirmed : Status()
data object Unconfirmed : Status()
}
enum class Direction {
INCOMING,
OUTGOING,
}
}
/** Subtitle variants for [Content] rows. */
@Immutable
sealed interface ContentSubtitle {
/** Plain text — for types without a directly-displayable address (Operation, GaslessFee, ClaimRewards, etc.). */
data class Plain(val text: TextReference) : ContentSubtitle
/**
* External counterparty address renders as "to/from: <identicon> <briefAddress>".
* Used for Transfer to/from external addresses.
*/
data class ExternalAddress(
val direction: Direction,
val rawAddress: String,
val briefAddress: String,
) : ContentSubtitle
/**
* Counterparty matches one of the user's own accounts renders as "to/from: <accountIcon> <accountName>".
*/
data class OwnAccount(
val direction: Direction,
val accountName: TextReference,
@DrawableRes val iconResId: Int,
val iconBackgroundColor: Color,
) : ContentSubtitle
/**
* Counterparty matches one of the user's own wallets (cross-wallet transfer with accounts mode disabled)
* renders as "to/from: <walletIcon> <walletName>".
*/
data class OwnWallet(
val direction: Direction,
val walletName: String,
val deviceIconUM: DeviceIconUM,
) : ContentSubtitle
enum class Direction { TO, FROM }
}
/**
* Compact status pill used for Staking / YieldMode / Approve transactions where the row format
* is replaced by a single chip with status-aware colors.
*
* @property kind controls leading icon and color tint
* @property status drives background/text colors and Failed/Unconfirmed icon override
* @property label full pill label text (already composed by converter, e.g. "Staked")
* @property amount optional signed numeric value rendered after [label] (e.g. "950.43");
* null for kinds that don't carry amount (Vote, Withdraw, Yield mode)
* @property currencySymbol currency symbol rendered after [amount]; null when [amount] is null
* @property subtitle optional subtitle (e.g. "to: 33Bd...ga2B" with avatar) for Approve
*/
data class Pill(
override val txHash: String,
val kind: PillKind,
val status: Content.Status,
val label: TextReference,
val amount: String?,
val currencySymbol: String?,
val subtitle: PillSubtitle?,
val timestamp: Long,
val onClick: () -> Unit,
) : TransactionItemUM
enum class PillKind {
STAKING,
YIELD_MODE,
APPROVE,
}
@Immutable
sealed interface PillSubtitle {
/** Address subtitle with inline IdentIcon (Blockies 8×8, hashed from [rawAddress]). */
data class Address(val rawAddress: String, val briefAddress: String) : PillSubtitle
}
data class Loading(override val txHash: String) : TransactionItemUM
data class Locked(override val txHash: String) : TransactionItemUM
}

View file

@ -61,6 +61,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import dev.chrisbanes.haze.HazeProgressive
@ -91,6 +92,9 @@ internal fun TokenDetailsScreen(
val rootBackground by LocalRootBackgroundColor.current
var marketBlockHeight by remember { mutableStateOf(0.dp) }
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
val fadeFloorHeight = TangemTheme.dimens.size100 + bottomBarHeight
val effectiveBottomPadding = maxOf(partialCollapsedHeight + marketBlockHeight, fadeFloorHeight)
val notificationModifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x4)
@ -121,7 +125,7 @@ internal fun TokenDetailsScreen(
yieldSupplyComponent = yieldSupplyComponent,
txHistoryComponent = txHistoryComponent,
rootBackground = rootBackground,
bottomContentPadding = marketBlockHeight,
bottomContentPadding = effectiveBottomPadding,
modifier = Modifier
.fillMaxSize()
.nestedScroll(behavior.nestedScrollConnection),
@ -291,13 +295,17 @@ private fun TokenDetailsScreen_Preview() {
override fun Content(modifier: Modifier) = Unit
},
txHistoryComponent = object : TxHistoryComponent {
override val txHistoryState: StateFlow<TxHistoryUM> = MutableStateFlow(
override val legacyTxHistoryState: StateFlow<TxHistoryUM> = MutableStateFlow(
value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}),
)
override val txHistoryState: StateFlow<TxHistoryItemsUM> = MutableStateFlow(
value = TxHistoryItemsUM.Empty(isBalanceHidden = false, onExploreClick = {}),
)
override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) = Unit
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) = Unit
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) = Unit
},
)
}

View file

@ -34,6 +34,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.e
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.yield.supply.api.YieldSupplyComponent
import kotlinx.coroutines.flow.MutableStateFlow
@ -56,7 +57,7 @@ internal fun TokenDetailsScreenLegacy(
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
val listState = rememberLazyListState()
val txHistoryComponentState by txHistoryComponent.txHistoryState.collectAsStateWithLifecycle()
val txHistoryComponentState by txHistoryComponent.legacyTxHistoryState.collectAsStateWithLifecycle()
val betweenItemsPadding = TangemTheme.dimens.spacing12
val horizontalPadding = TangemTheme.dimens.spacing16
val itemModifier = Modifier
@ -177,13 +178,17 @@ private fun TokenDetailsScreenPreview(
state = state,
tokenMarketBlockComponent = null,
txHistoryComponent = object : TxHistoryComponent {
override val txHistoryState: StateFlow<TxHistoryUM> = MutableStateFlow(
override val legacyTxHistoryState: StateFlow<TxHistoryUM> = MutableStateFlow(
value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}),
)
override val txHistoryState: StateFlow<TxHistoryItemsUM> = MutableStateFlow(
value = TxHistoryItemsUM.Empty(isBalanceHidden = false, onExploreClick = {}),
)
override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) = Unit
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) = Unit
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) = Unit
},
yieldSupplyComponent = object : YieldSupplyComponent {
@Composable

View file

@ -21,6 +21,7 @@ dependencies {
/** Compose */
implementation(deps.compose.runtime)
implementation(deps.compose.foundation)
implementation(deps.compose.material3)
implementation(deps.compose.ui.tooling)
/** Other */

View file

@ -6,17 +6,20 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import kotlinx.coroutines.flow.StateFlow
@Stable
interface TxHistoryComponent {
val txHistoryState: StateFlow<TxHistoryUM>
val legacyTxHistoryState: StateFlow<TxHistoryUM>
val txHistoryState: StateFlow<TxHistoryItemsUM>
fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM)
fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM)
fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM)
data class Params(
val userWalletId: UserWalletId,

View file

@ -0,0 +1,72 @@
package com.tangem.features.txhistory.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Transaction history state for Token Details ([REDACTED_TASK_KEY]).
*
* Parallel to [TxHistoryUM] but uses [TransactionItemUM] for transaction items so the
* `TransactionItem` composable can render structured fields without parsing.
*/
@Immutable
sealed interface TxHistoryItemsUM {
val isBalanceHidden: Boolean
data class Loading(
override val isBalanceHidden: Boolean,
val onExploreClick: () -> Unit,
) : TxHistoryItemsUM {
val items = persistentListOf(
TxHistoryItemUM.Transaction(TransactionItemUM.Loading("LOADING_TX_HASH_1")),
TxHistoryItemUM.Transaction(TransactionItemUM.Loading("LOADING_TX_HASH_2")),
TxHistoryItemUM.Transaction(TransactionItemUM.Loading("LOADING_TX_HASH_3")),
TxHistoryItemUM.Transaction(TransactionItemUM.Loading("LOADING_TX_HASH_4")),
)
}
data class Content(
override val isBalanceHidden: Boolean,
val items: ImmutableList<TxHistoryItemUM>,
val isLoadingMore: Boolean,
val loadMore: () -> Boolean,
) : TxHistoryItemsUM
data class Empty(override val isBalanceHidden: Boolean, val onExploreClick: () -> Unit) : TxHistoryItemsUM
data class NotSupported(
override val isBalanceHidden: Boolean,
val pendingTransactions: ImmutableList<TransactionItemUM>,
val onExploreClick: () -> Unit,
) : TxHistoryItemsUM
data class Error(
override val isBalanceHidden: Boolean,
val onReloadClick: () -> Unit,
val onExploreClick: () -> Unit,
) : TxHistoryItemsUM
fun copySealed(isBalanceHidden: Boolean): TxHistoryItemsUM {
return when (this) {
is Content -> copy(isBalanceHidden = isBalanceHidden)
is NotSupported -> copy(isBalanceHidden = isBalanceHidden)
is Empty -> copy(isBalanceHidden = isBalanceHidden)
is Error -> copy(isBalanceHidden = isBalanceHidden)
is Loading -> copy(isBalanceHidden = isBalanceHidden)
}
}
@Immutable
sealed interface TxHistoryItemUM {
data class GroupTitle(
val title: String,
val itemKey: String,
) : TxHistoryItemUM
data class Transaction(val state: TransactionItemUM) : TxHistoryItemUM
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
@ -8,7 +9,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.layout.layoutId
@ -18,13 +22,19 @@ import androidx.compose.ui.util.lerp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.CircleShimmer
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.transactions.TransactionItem
import com.tangem.core.ui.components.transactions.TxHistoryDateHeader
import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock
import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryItemsUM.TxHistoryItemUM
private val LoadingTitleShimmerWidth = 52.dp
private val LoadingPrimaryShimmerWidth = 110.dp
@ -33,56 +43,112 @@ private val LoadingEndTopShimmerWidth = 107.dp
private val LoadingEndBottomShimmerWidth = 52.dp
private const val LOADING_TRANSACTION_MIN_ALPHA = 0.1f
private const val LOAD_MORE_BUFFER = 20
fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) {
fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryItemsUM) {
when (state) {
is TxHistoryUM.Content -> contentItems(listState, state)
is TxHistoryUM.Empty -> emptyItem(state)
is TxHistoryUM.Error -> errorItem(state)
is TxHistoryUM.Loading -> loadingItems(state)
is TxHistoryUM.NotSupported -> notSupportedItem(state)
is TxHistoryItemsUM.Content -> contentItems(listState, state)
is TxHistoryItemsUM.Empty -> emptyItem(state)
is TxHistoryItemsUM.Error -> errorItem(state)
is TxHistoryItemsUM.Loading -> loadingItems(state)
is TxHistoryItemsUM.NotSupported -> notSupportedItem(state)
}
}
@Suppress("UNUSED_PARAMETER")
private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) {
item(key = "tx_history_content", contentType = "tx_history_content") {
TxHistoryContentBlock(state = state)
private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryItemsUM.Content) {
items(
items = state.items,
key = { item ->
when (item) {
is TxHistoryItemUM.GroupTitle -> "group_title:${item.itemKey}"
is TxHistoryItemUM.Transaction -> "tx:${item.state.txHash}"
}
},
contentType = { item -> item::class.java },
) { item ->
when (item) {
is TxHistoryItemUM.GroupTitle -> TxHistoryDateHeader(title = item.title)
is TxHistoryItemUM.Transaction -> TransactionItem(
state = item.state,
isBalanceHidden = state.isBalanceHidden,
)
}
}
item(key = "tx_history_load_more", contentType = "tx_history_load_more") {
TxHistoryLoadMoreFooter(
listState = listState,
isLoadingMore = state.isLoadingMore,
onLoadMore = state.loadMore,
)
}
}
private fun LazyListScope.emptyItem(state: TxHistoryUM.Empty) {
@Composable
private fun TxHistoryLoadMoreFooter(
listState: LazyListState,
isLoadingMore: Boolean,
onLoadMore: () -> Boolean,
modifier: Modifier = Modifier,
) {
InfiniteListHandler(
listState = listState,
buffer = LOAD_MORE_BUFFER,
onLoadMore = onLoadMore,
)
if (isLoadingMore) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x4),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(TangemTheme.dimens2.x6),
color = TangemTheme.colors2.graphic.neutral.tertiaryConstant,
strokeWidth = TangemTheme.dimens2.x0_5,
)
}
}
}
private fun LazyListScope.emptyItem(state: TxHistoryItemsUM.Empty) {
item(key = "tx_history_empty", contentType = "tx_history_empty") {
TxHistoryEmptyBlock(state = state)
}
}
private fun LazyListScope.errorItem(state: TxHistoryUM.Error) {
private fun LazyListScope.errorItem(state: TxHistoryItemsUM.Error) {
item(key = "tx_history_error", contentType = "tx_history_error") {
TxHistoryErrorBlock(state = state)
}
}
private fun LazyListScope.loadingItems(state: TxHistoryUM.Loading) {
private fun LazyListScope.loadingItems(state: TxHistoryItemsUM.Loading) {
item(key = "tx_history_loading", contentType = "tx_history_loading") {
TxHistoryLoadingBlock(state = state)
}
}
private fun LazyListScope.notSupportedItem(state: TxHistoryUM.NotSupported) {
private fun LazyListScope.notSupportedItem(state: TxHistoryItemsUM.NotSupported) {
if (state.pendingTransactions.isNotEmpty()) {
item(key = "tx_history_pending_header", contentType = "tx_history_pending_header") {
TxHistoryDateHeader(title = stringResourceSafe(R.string.transaction_history_pending))
}
items(
items = state.pendingTransactions,
key = { item -> "pending_tx:${item.txHash}" },
contentType = { TransactionItemUM::class.java },
) { item ->
TransactionItem(state = item, isBalanceHidden = state.isBalanceHidden)
}
}
item(key = "tx_history_not_supported", contentType = "tx_history_not_supported") {
TxHistoryNotSupportedBlock(state = state)
}
}
@Suppress("UNUSED_PARAMETER")
@Composable
private fun TxHistoryContentBlock(state: TxHistoryUM.Content, modifier: Modifier = Modifier) {
// TODO [REDACTED_TASK_KEY] redesign Content state
}
@Composable
private fun TxHistoryEmptyBlock(state: TxHistoryUM.Empty, modifier: Modifier = Modifier) {
private fun TxHistoryEmptyBlock(state: TxHistoryItemsUM.Empty, modifier: Modifier = Modifier) {
EmptyTransactionBlock(
state = EmptyTransactionsBlockState.Empty(
onExplore = state.onExploreClick,
@ -93,7 +159,7 @@ private fun TxHistoryEmptyBlock(state: TxHistoryUM.Empty, modifier: Modifier = M
}
@Composable
private fun TxHistoryErrorBlock(state: TxHistoryUM.Error, modifier: Modifier = Modifier) {
private fun TxHistoryErrorBlock(state: TxHistoryItemsUM.Error, modifier: Modifier = Modifier) {
EmptyTransactionBlock(
state = EmptyTransactionsBlockState.FailedToLoad(
onReload = state.onReloadClick,
@ -106,31 +172,20 @@ private fun TxHistoryErrorBlock(state: TxHistoryUM.Error, modifier: Modifier = M
}
@Composable
private fun TxHistoryLoadingBlock(state: TxHistoryUM.Loading, modifier: Modifier = Modifier) {
val transactionCount = state.items.count { it is TxHistoryUM.TxHistoryItemUM.Transaction }
private fun TxHistoryLoadingBlock(state: TxHistoryItemsUM.Loading, modifier: Modifier = Modifier) {
val lastIndex = state.items.lastIndex
Column(modifier = modifier.fillMaxWidth()) {
var transactionIndex = 0
state.items.forEach { item ->
when (item) {
is TxHistoryUM.TxHistoryItemUM.Title -> TxHistoryLoadingTitle()
is TxHistoryUM.TxHistoryItemUM.Transaction -> {
val fraction = if (transactionCount <= 1) {
0f
} else {
transactionIndex.toFloat() / (transactionCount - 1)
}
val alpha = lerp(start = 1f, stop = LOADING_TRANSACTION_MIN_ALPHA, fraction = fraction)
TxHistoryLoadingTransaction(modifier = Modifier.alpha(alpha))
transactionIndex++
}
is TxHistoryUM.TxHistoryItemUM.GroupTitle -> Unit
}
TxHistoryLoadingDateHeader()
state.items.forEachIndexed { index, _ ->
val fraction = if (lastIndex <= 0) 0f else index.toFloat() / lastIndex
val alpha = lerp(start = 1f, stop = LOADING_TRANSACTION_MIN_ALPHA, fraction = fraction)
TxHistoryLoadingTransaction(modifier = Modifier.alpha(alpha))
}
}
}
@Composable
private fun TxHistoryLoadingTitle(modifier: Modifier = Modifier) {
private fun TxHistoryLoadingDateHeader(modifier: Modifier = Modifier) {
RectangleShimmer(
modifier = modifier
.padding(
@ -187,7 +242,7 @@ private fun TxHistoryLoadingTransaction(modifier: Modifier = Modifier) {
}
@Composable
private fun TxHistoryNotSupportedBlock(state: TxHistoryUM.NotSupported, modifier: Modifier = Modifier) {
private fun TxHistoryNotSupportedBlock(state: TxHistoryItemsUM.NotSupported, modifier: Modifier = Modifier) {
EmptyTransactionBlock(
state = EmptyTransactionsBlockState.NotImplemented(
onExplore = state.onExploreClick,
@ -204,7 +259,7 @@ private fun TxHistoryNotSupportedBlock(state: TxHistoryUM.NotSupported, modifier
private fun TxHistoryLoadingBlock_Preview() {
TangemThemePreviewRedesign {
TxHistoryLoadingBlock(
state = TxHistoryUM.Loading(
state = TxHistoryItemsUM.Loading(
isBalanceHidden = false,
onExploreClick = {},
),

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.txhistory.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Project - API */
implementation(projects.features.txhistory.api)
@ -20,6 +24,7 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.common.routing)
implementation(projects.common.ui)
implementation(projects.core.configToggles)
implementation(projects.core.analytics)
implementation(projects.core.pagination)
@ -58,4 +63,10 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.decompose.ext.compose)
/* Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.txhistory.model.TxHistoryModel
import com.tangem.features.txhistory.ui.txHistoryItems
@ -20,14 +21,17 @@ internal class DefaultTxHistoryComponent @AssistedInject constructor(
private val model: TxHistoryModel = getOrCreateModel(params)
override val txHistoryState: StateFlow<TxHistoryUM>
override val legacyTxHistoryState: StateFlow<TxHistoryUM>
get() = model.legacyUiState
override val txHistoryState: StateFlow<TxHistoryItemsUM>
get() = model.uiState
override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) {
txHistoryItemsLegacy(listState, state)
}
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) {
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) {
txHistoryItems(listState, state)
}

View file

@ -0,0 +1,374 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.common.ui.account.getResId
import com.tangem.common.ui.account.getUiColor
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.extensions.TextReference
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.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.converter.TxHistoryStatusPillConverter.Input as PillInput
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.toBriefAddressFormat
/**
* Converts [TxInfo] to [TransactionItemUM] for transaction history.
*
* Single dispatch: each [TransactionType] is mapped exactly once in [convert] to either a [TransactionItemUM.Pill]
* or a [TransactionItemUM.Content]. Per-type metadata (labels, icons, subtitles) lives in one branch no parallel
* `when`s to keep in sync.
*
* The high cyclomatic complexity of [convert] is structural it mirrors the [TransactionType] sealed hierarchy.
* Splitting it would re-introduce the parallel-`when`s problem; the suppression is intentional.
*/
internal class TxHistoryItemToTransactionItemUMConverter(
private val currency: CryptoCurrency,
private val txHistoryUiActions: TxHistoryUiActions,
private val lookupContext: TxHistoryLookupContext? = null,
) : Converter<TxInfo, TransactionItemUM> {
private val pillConverter = TxHistoryStatusPillConverter(currency, txHistoryUiActions)
@Suppress("CyclomaticComplexMethod")
override fun convert(value: TxInfo): TransactionItemUM {
val uiStatus = value.status.toUiStatus()
return when (val type = value.type) {
// region Pill
is TransactionType.Approve -> pillConverter.convert(PillInput(value, uiStatus, ApproveSpec))
is TransactionType.Staking.Stake -> pillConverter.convert(PillInput(value, uiStatus, StakeSpec))
is TransactionType.Staking.Unstake -> pillConverter.convert(PillInput(value, uiStatus, UnstakeSpec))
is TransactionType.Staking.Restake -> pillConverter.convert(PillInput(value, uiStatus, RestakeSpec))
is TransactionType.Staking.Vote -> pillConverter.convert(PillInput(value, uiStatus, VoteSpec))
is TransactionType.Staking.Withdraw -> pillConverter.convert(PillInput(value, uiStatus, WithdrawSpec))
is TransactionType.YieldSupply.Enter -> pillConverter.convert(PillInput(value, uiStatus, YieldEnterSpec))
is TransactionType.YieldSupply.Exit -> pillConverter.convert(PillInput(value, uiStatus, YieldExitSpec))
// endregion
// region Content
is TransactionType.Operation -> operationContent(value, uiStatus, type)
is TransactionType.Swap -> swapContent(value, uiStatus)
is TransactionType.Transfer -> transferContent(value, uiStatus)
is TransactionType.Staking.ClaimRewards -> claimRewardsContent(value, uiStatus)
is TransactionType.YieldSupply.Topup -> yieldTopupContent(value, uiStatus, type)
is TransactionType.YieldSupply.Send -> yieldSendContent(value, uiStatus, type)
is TransactionType.YieldSupply.DeployContract -> yieldDeployContractContent(value, uiStatus, type)
is TransactionType.YieldSupply.InitializeToken -> yieldInitializeTokenContent(value, uiStatus, type)
is TransactionType.YieldSupply.ReactivateToken -> yieldReactivateTokenContent(value, uiStatus, type)
is TransactionType.UnknownOperation -> unknownOperationContent(value, uiStatus)
is TransactionType.GaslessFee -> gaslessFeeContent(value, uiStatus)
// endregion
}
}
private fun operationContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
type: TransactionType.Operation,
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = stringReference(type.name),
iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
)
private fun swapContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
buildContent(
tx = tx,
uiStatus = uiStatus,
title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
)
private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content {
val counterpartyAddress = (tx.interactionAddressType as? TxInfo.InteractionAddressType.User)?.address
val direction = if (tx.isOutgoing) ContentSubtitle.Direction.TO else ContentSubtitle.Direction.FROM
val ownSubtitle = counterpartyAddress?.let { resolveOwnSubtitle(lookupContext, it, direction) }
val title = when {
ownSubtitle != null -> tx.statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
tx.isOutgoing -> tx.statusAwareTitle(R.string.common_sending, R.string.common_sent)
else -> tx.statusAwareTitle(R.string.common_receiving, R.string.common_received)
}
val subtitle = ownSubtitle ?: when {
counterpartyAddress != null -> ContentSubtitle.ExternalAddress(
direction = direction,
rawAddress = counterpartyAddress,
briefAddress = counterpartyAddress.toBriefAddressFormat(),
)
else -> ContentSubtitle.Plain(tx.extractSubtitleByAddressType())
}
return buildContent(
tx = tx,
uiStatus = uiStatus,
title = title,
iconRes = tx.directionalIcon(),
subtitle = subtitle,
)
}
private fun claimRewardsContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
buildContent(
tx = tx,
uiStatus = uiStatus,
title = tx.statusAwareTitle(
pending = R.string.transaction_history_claiming_reward,
confirmed = R.string.transaction_history_staking_reward,
),
iconRes = R.drawable.ic_transaction_history_claim_rewards_24,
subtitle = ContentSubtitle.Plain(resourceReference(R.string.transaction_history_earned_from_stake)),
)
private fun yieldTopupContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
type: TransactionType.YieldSupply.Topup,
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_topup),
iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
)
private fun yieldDeployContractContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
type: TransactionType.YieldSupply.DeployContract,
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_deploy_contract),
iconRes = R.drawable.ic_doc_24,
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
)
private fun yieldInitializeTokenContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
type: TransactionType.YieldSupply.InitializeToken,
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_initialize),
iconRes = R.drawable.ic_gear_24,
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
)
private fun yieldReactivateTokenContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
type: TransactionType.YieldSupply.ReactivateToken,
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_reactivate),
iconRes = R.drawable.ic_refresh_24,
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
)
private fun yieldSendContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
type: TransactionType.YieldSupply.Send,
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = if (type.isYieldSupplyWithdraw || tx.isOutgoing) {
resourceReference(R.string.yield_module_transaction_withdraw)
} else {
resourceReference(R.string.common_transfer)
},
iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing,
)
private fun unknownOperationContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.transaction_history_operation),
iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
)
private fun gaslessFeeContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.gasless_transaction_fee),
iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
)
private fun buildContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
title: TextReference,
iconRes: Int,
subtitle: ContentSubtitle,
hideAmount: Boolean = false,
): TransactionItemUM.Content = TransactionItemUM.Content(
txHash = tx.txHash,
amount = if (hideAmount) "" else tx.formatContentAmount(currency),
currencySymbol = if (hideAmount) "" else currency.symbol,
time = tx.timestampInMillis.toTimeFormat(),
status = uiStatus,
direction = tx.extractDirection(),
iconRes = if (uiStatus is TransactionItemUM.Content.Status.Failed) R.drawable.ic_close_24 else iconRes,
title = title,
subtitle = subtitle,
timestamp = tx.timestampInMillis,
onClick = { txHistoryUiActions.openTxInExplorer(tx.txHash) },
)
}
// region Content building helpers
private fun TxInfo.formatContentAmount(currency: CryptoCurrency): String {
val prefix = when {
status is TxInfo.TransactionStatus.Failed -> ""
amount.isZero() -> ""
type is TransactionType.Staking.ClaimRewards -> ""
else -> if (isOutgoing) StringsSigns.MINUS else StringsSigns.PLUS
}
return prefix + amount.format { crypto(symbol = "", decimals = currency.decimals) }.trim()
}
// endregion
// region Subtitles
private fun resolveOwnSubtitle(
lookupContext: TxHistoryLookupContext?,
address: String,
direction: ContentSubtitle.Direction,
): ContentSubtitle? {
val ctx = lookupContext ?: return null
val account = ctx.ownAccountByAddress[address] ?: return null
return if (ctx.isAccountsModeEnabled) {
ContentSubtitle.OwnAccount(
direction = direction,
accountName = account.accountName.toUM().value,
iconResId = account.icon.value.getResId(),
iconBackgroundColor = account.icon.color.getUiColor(),
)
} else {
val walletInfo = ctx.walletInfoById[account.accountId.userWalletId] ?: return null
ContentSubtitle.OwnWallet(
direction = direction,
walletName = walletInfo.name,
deviceIconUM = walletInfo.deviceIconUM,
)
}
}
private fun TxInfo.yieldSupplySubtitle(currency: CryptoCurrency, type: TransactionType.YieldSupply): TextReference {
if (currency is CryptoCurrency.Coin) {
return if (type is TransactionType.YieldSupply.Send) {
extractSubtitleByAddressType()
} else {
resourceReference(
R.string.transaction_history_transaction_for_address,
wrappedList(type.address?.toBriefAddressFormat().orEmpty()),
)
}
}
return when (type) {
is TransactionType.YieldSupply.Enter ->
amountSubtitle(currency, R.string.yield_module_transaction_enter_subtitle)
TransactionType.YieldSupply.Topup ->
amountSubtitle(currency, R.string.yield_module_transaction_topup_subtitle)
is TransactionType.YieldSupply.Exit ->
amountSubtitle(currency, R.string.yield_module_transaction_exit_subtitle)
is TransactionType.YieldSupply.Send -> if (!isOutgoing && type.isYieldSupplyWithdraw) {
amountSubtitle(currency, R.string.yield_module_transaction_exit_subtitle)
} else {
extractSubtitleByAddressType()
}
else -> extractSubtitleByAddressType()
}
}
private fun TxInfo.amountSubtitle(currency: CryptoCurrency, @StringRes resId: Int): TextReference {
val formatted = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
return resourceReference(resId, wrappedList(formatted))
}
private fun TxInfo.extractSubtitleByAddressType(): TextReference =
when (val interactionAddress = interactionAddressType) {
is TxInfo.InteractionAddressType.Contract -> resourceReference(
id = R.string.transaction_history_contract_address,
formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()),
)
is TxInfo.InteractionAddressType.Multiple -> resourceReference(
id = directionalAddressRes(),
formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)),
)
is TxInfo.InteractionAddressType.User -> resourceReference(
id = directionalAddressRes(),
formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()),
)
is TxInfo.InteractionAddressType.Validator -> resourceReference(
id = R.string.transaction_history_transaction_validator,
formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()),
)
null -> TextReference.EMPTY
}
private fun TxInfo.directionalAddressRes(): Int = if (isOutgoing) {
R.string.transaction_history_transaction_to_address
} else {
R.string.transaction_history_transaction_from_address
}
// endregion
// region Labels
private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) {
is TxInfo.TransactionStatus.Failed ->
resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending)))
is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending)
is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed)
}
// endregion
// region Misc
private fun TxInfo.directionalIcon(): Int = if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
private fun TxInfo.extractDirection(): TransactionItemUM.Content.Direction = if (isOutgoing) {
TransactionItemUM.Content.Direction.OUTGOING
} else {
TransactionItemUM.Content.Direction.INCOMING
}
private fun TxInfo.TransactionStatus.toUiStatus(): TransactionItemUM.Content.Status = when (this) {
TxInfo.TransactionStatus.Confirmed -> TransactionItemUM.Content.Status.Confirmed
TxInfo.TransactionStatus.Failed -> TransactionItemUM.Content.Status.Failed
TxInfo.TransactionStatus.Unconfirmed -> TransactionItemUM.Content.Status.Unconfirmed
}
// endregion

View file

@ -0,0 +1,157 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.PillKind
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
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.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.converter.Converter
import com.tangem.utils.toBriefAddressFormat
internal class TxHistoryStatusPillConverter(
private val currency: CryptoCurrency,
private val txHistoryUiActions: TxHistoryUiActions,
) : Converter<TxHistoryStatusPillConverter.Input, TransactionItemUM.Pill> {
override fun convert(value: Input): TransactionItemUM.Pill {
val tx = value.tx
val uiStatus = value.uiStatus
val spec = value.spec
val hasAmount = spec.amount.show(uiStatus)
return TransactionItemUM.Pill(
txHash = tx.txHash,
kind = spec.kind,
status = uiStatus,
label = spec.labels.resolve(uiStatus),
amount = if (hasAmount) {
tx.amount.format { crypto(symbol = "", decimals = currency.decimals) }.trim()
} else {
null
},
currencySymbol = if (hasAmount) currency.symbol else null,
subtitle = tx.buildPillSubtitle(uiStatus),
timestamp = tx.timestampInMillis,
onClick = { txHistoryUiActions.openTxInExplorer(tx.txHash) },
)
}
data class Input(
val tx: TxInfo,
val uiStatus: TransactionItemUM.Content.Status,
val spec: PillSpec,
)
}
internal data class PillSpec(
val kind: PillKind,
val labels: PillLabels,
val amount: PillAmount,
)
internal data class PillLabels(
@StringRes val confirmed: Int,
@StringRes val pending: Int,
@StringRes val failedBase: Int = pending,
val hasFailedTemplate: Boolean = true,
)
internal enum class PillAmount {
ALWAYS, NEVER, IF_NOT_FAILED;
fun show(status: TransactionItemUM.Content.Status): Boolean = when (this) {
ALWAYS -> true
NEVER -> false
IF_NOT_FAILED -> status !is TransactionItemUM.Content.Status.Failed
}
}
internal val ApproveSpec = PillSpec(
kind = PillKind.APPROVE,
labels = PillLabels(
confirmed = R.string.common_approved,
pending = R.string.common_approving,
hasFailedTemplate = false,
),
amount = PillAmount.ALWAYS,
)
internal val StakeSpec = PillSpec(
kind = PillKind.STAKING,
labels = PillLabels(R.string.common_staked, R.string.common_staking),
amount = PillAmount.IF_NOT_FAILED,
)
internal val UnstakeSpec = PillSpec(
kind = PillKind.STAKING,
labels = PillLabels(R.string.staking_unstaked, R.string.staking_unstaking),
amount = PillAmount.IF_NOT_FAILED,
)
internal val RestakeSpec = PillSpec(
kind = PillKind.STAKING,
labels = PillLabels(
confirmed = R.string.transaction_history_rewards_restaked,
pending = R.string.transaction_history_rewards_restaking,
),
amount = PillAmount.IF_NOT_FAILED,
)
internal val VoteSpec = PillSpec(
kind = PillKind.STAKING,
labels = PillLabels(
confirmed = R.string.staking_vote,
pending = R.string.common_voting,
failedBase = R.string.staking_vote,
),
amount = PillAmount.NEVER,
)
internal val WithdrawSpec = PillSpec(
kind = PillKind.STAKING,
labels = PillLabels(
confirmed = R.string.staking_withdraw,
pending = R.string.common_withdrawing,
failedBase = R.string.staking_withdraw,
),
amount = PillAmount.NEVER,
)
internal val YieldEnterSpec = PillSpec(
kind = PillKind.YIELD_MODE,
labels = PillLabels(
confirmed = R.string.yield_module_transaction_enter,
pending = R.string.yield_module_token_details_earn_notification_processing,
failedBase = R.string.common_yield_mode,
),
amount = PillAmount.NEVER,
)
internal val YieldExitSpec = PillSpec(
kind = PillKind.YIELD_MODE,
labels = PillLabels(
confirmed = R.string.yield_module_transaction_exit,
pending = R.string.transaction_history_disabling_yield_mode,
),
amount = PillAmount.NEVER,
)
private fun TxInfo.buildPillSubtitle(status: TransactionItemUM.Content.Status): TransactionItemUM.PillSubtitle? {
if (type !is TransactionType.Approve) return null
if (status is TransactionItemUM.Content.Status.Failed) return null
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
return TransactionItemUM.PillSubtitle.Address(
rawAddress = address,
briefAddress = address.toBriefAddressFormat(),
)
}
private fun PillLabels.resolve(status: TransactionItemUM.Content.Status): TextReference = when (status) {
is TransactionItemUM.Content.Status.Confirmed -> resourceReference(confirmed)
is TransactionItemUM.Content.Status.Unconfirmed -> resourceReference(pending)
is TransactionItemUM.Content.Status.Failed -> if (hasFailedTemplate) {
resourceReference(R.string.common_action_failed, wrappedList(resourceReference(failedBase)))
} else {
resourceReference(failedBase)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.txhistory.model
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWalletId
/**
* Per-page lookup context for the tx-history converter.
*
* - [ownAccountByAddress] / [walletInfoById] address-keyed lookups for resolving counterparty owners
* in transfer subtitles ("to / from MY account / wallet").
* - [isAccountsModeEnabled] toggles whether a resolved owner is rendered as account or wallet.
*/
internal data class TxHistoryLookupContext(
val ownAccountByAddress: Map<String, Account.CryptoPortfolio>,
val isAccountsModeEnabled: Boolean,
val walletInfoById: Map<UserWalletId, WalletInfo>,
)
internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM)

View file

@ -2,30 +2,49 @@ package com.tangem.features.txhistory.model
import androidx.compose.runtime.Stable
import arrow.core.Option
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.txhistory.entity.TxHistoryUpdateListener
import com.tangem.features.txhistory.state.TxHistoryStateController
import com.tangem.features.txhistory.utils.TxHistoryListManager
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@ -38,29 +57,66 @@ internal class TxHistoryModel @Inject constructor(
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletIconUMConverter: WalletIconUMConverter,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val urlOpener: UrlOpener,
private val txHistoryUpdateListener: TxHistoryUpdateListener,
private val stateController: TxHistoryStateController,
private val designFeatureToggles: DesignFeatureToggles,
repository: TxHistoryRepositoryV2,
paramsContainer: ParamsContainer,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
userWalletsListRepository: UserWalletsListRepository,
) : Model(), TxHistoryUiActions {
private val params: TxHistoryComponent.Params = paramsContainer.require()
private val txHistoryItemConverter =
private val lookupDataFlow: Flow<TxHistoryLookupContext> = if (designFeatureToggles.isRedesignEnabled) {
combine(
flow = multiAccountStatusListSupplier(),
flow2 = isAccountsModeEnabledUseCase(),
flow3 = userWalletsListRepository.userWallets.filterNotNull(),
transform = ::Triple,
)
.map { (accountLists, modeEnabled, wallets) ->
TxHistoryLookupContext(
ownAccountByAddress = buildOwnAccountAddressMap(accountLists),
isAccountsModeEnabled = modeEnabled,
walletInfoById = wallets.associate { wallet ->
wallet.walletId to WalletInfo(
name = wallet.name,
deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(wallet)),
)
},
)
}
.distinctUntilChanged()
.flowOn(dispatchers.default)
.shareIn(modelScope, SharingStarted.WhileSubscribed(), replay = 1)
} else {
emptyFlow()
}
private val legacyTxHistoryItemConverter =
TxHistoryItemToTransactionStateConverter(currency = params.currency, txHistoryUiActions = this)
private val txHistoryListManager = TxHistoryListManager(
repository = repository,
dispatchers = dispatchers,
userWalletId = params.userWalletId,
currency = params.currency,
txHistoryItemConverter = txHistoryItemConverter,
designFeatureToggles = designFeatureToggles,
txHistoryUiActions = this,
lookupDataFlow = lookupDataFlow,
legacyTxHistoryItemConverter = legacyTxHistoryItemConverter,
)
private val _uiState: MutableStateFlow<TxHistoryUM> =
MutableStateFlow(TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = ::openExplorer))
val uiState: StateFlow<TxHistoryUM> = _uiState.asStateFlow()
val legacyUiState = stateController.legacyUiState
val uiState = stateController.uiState
init {
stateController.setLoading(isBalanceHidden = true, onExploreClick = ::openExplorer)
handleBalanceHiding()
subscribeToUiItemChanges()
initListManager()
@ -69,9 +125,26 @@ internal class TxHistoryModel @Inject constructor(
subscribeOnCurrencyStatusUpdates()
}
private fun buildOwnAccountAddressMap(lists: List<AccountStatusList>): Map<String, Account.CryptoPortfolio> {
val networkRawId = params.currency.network.id.rawId
val map = mutableMapOf<String, Account.CryptoPortfolio>()
lists.forEach { accountList ->
accountList.accountStatuses
.filterCryptoPortfolio()
.forEach { status: AccountStatus.CryptoPortfolio ->
status.flattenCurrencies().forEach { currencyStatus ->
if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach
val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach
map[address] = status.account
}
}
}
return map
}
private fun subscribeToUiItemChanges() {
txHistoryListManager.uiItems
.onEach { updateState(it) }
.onEach { snapshot -> stateController.setContent(snapshot = snapshot, loadMore = ::loadMoreItems) }
.launchIn(modelScope)
txHistoryListManager.paginationStatus
.onEach { paginationStatus -> handlePaginationStatus(paginationStatus) }
@ -89,7 +162,7 @@ internal class TxHistoryModel @Inject constructor(
}
private fun loadTxInfo() {
_uiState.update { state -> getLoadingState(state.isBalanceHidden) }
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
modelScope.launch {
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
@ -98,12 +171,9 @@ internal class TxHistoryModel @Inject constructor(
}
fun reload() {
// fast exit
if (uiState.value is TxHistoryUM.NotSupported) return
if (stateController.isNotSupported) return
_uiState.update { state ->
if (state !is TxHistoryUM.Content) getLoadingState(state.isBalanceHidden) else state
}
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
modelScope.launch {
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
.onLeft(::handleErrorState)
@ -113,7 +183,9 @@ internal class TxHistoryModel @Inject constructor(
private fun handleBalanceHiding() {
getBalanceHidingSettingsUseCase()
.onEach { _uiState.update { state -> state.copySealed(isBalanceHidden = it.isBalanceHidden) } }
.map { it.isBalanceHidden }
.distinctUntilChanged()
.onEach(stateController::updateBalanceHidden)
.launchIn(modelScope)
}
@ -122,85 +194,70 @@ internal class TxHistoryModel @Inject constructor(
return true
}
private fun updateState(items: ImmutableList<TxHistoryUM.TxHistoryItemUM>) {
_uiState.update { state ->
if (state is TxHistoryUM.Content) {
state.copy(items = items)
} else {
TxHistoryUM.Content(
items = items,
isBalanceHidden = state.isBalanceHidden,
loadMore = ::loadMoreItems,
)
}
}
}
private fun handlePaginationStatus(status: PaginationStatus<*>) {
_uiState.update { state ->
when (status) {
is PaginationStatus.InitialLoadingError -> getErrorState(state.isBalanceHidden)
PaginationStatus.EndOfPagination,
PaginationStatus.InitialLoading,
PaginationStatus.NextBatchLoading,
PaginationStatus.None,
is PaginationStatus.Paginating<*>,
-> state
}
when (status) {
is PaginationStatus.InitialLoadingError -> stateController.setError(
onReloadClick = ::reload,
onExploreClick = ::openExplorer,
)
PaginationStatus.NextBatchLoading -> stateController.updateLoadingMore(isLoadingMore = true)
PaginationStatus.EndOfPagination,
is PaginationStatus.Paginating<*>,
-> stateController.updateLoadingMore(isLoadingMore = false)
PaginationStatus.InitialLoading,
PaginationStatus.None,
-> Unit
}
}
private fun handleErrorState(error: TxHistoryStateError) {
_uiState.update { state ->
when (error) {
is TxHistoryStateError.DataError -> getErrorState(isBalanceHidden = state.isBalanceHidden)
TxHistoryStateError.EmptyTxHistories -> TxHistoryUM.Empty(
isBalanceHidden = state.isBalanceHidden,
onExploreClick = ::openExplorer,
)
TxHistoryStateError.TxHistoryNotImplemented -> TxHistoryUM.NotSupported(
isBalanceHidden = state.isBalanceHidden,
pendingTransactions = persistentListOf(),
onExploreClick = ::openExplorer,
)
}
when (error) {
is TxHistoryStateError.DataError -> stateController.setError(
onReloadClick = ::reload,
onExploreClick = ::openExplorer,
)
TxHistoryStateError.EmptyTxHistories -> stateController.setEmpty(onExploreClick = ::openExplorer)
TxHistoryStateError.TxHistoryNotImplemented -> stateController.setNotSupported(
onExploreClick = ::openExplorer,
)
}
}
private fun getErrorState(isBalanceHidden: Boolean): TxHistoryUM.Error {
return TxHistoryUM.Error(
isBalanceHidden = isBalanceHidden,
onReloadClick = ::reload,
onExploreClick = ::openExplorer,
)
}
private fun getLoadingState(isBalanceHidden: Boolean): TxHistoryUM.Loading {
return TxHistoryUM.Loading(isBalanceHidden = isBalanceHidden, onExploreClick = ::openExplorer)
}
private fun subscribeOnCurrencyStatusUpdates() {
singleAccountStatusListSupplier(params.userWalletId)
val statusFlow = singleAccountStatusListSupplier(params.userWalletId)
.map { it.getCryptoCurrencyStatus(currency = params.currency) }
.distinctUntilChanged()
.onEach(::handlePendingTxsChanges)
val combined: Flow<Pair<Option<CryptoCurrencyStatus>, TxHistoryLookupContext?>> =
if (designFeatureToggles.isRedesignEnabled) {
combine(statusFlow, lookupDataFlow) { status, lookup -> status to lookup }
} else {
statusFlow.map { it to null }
}
combined
.onEach { (status, lookup) -> handlePendingTxsChanges(status, lookup) }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun handlePendingTxsChanges(maybeCurrencyStatus: Option<CryptoCurrencyStatus>) {
private fun handlePendingTxsChanges(
maybeCurrencyStatus: Option<CryptoCurrencyStatus>,
lookupContext: TxHistoryLookupContext?,
) {
maybeCurrencyStatus.onSome { status ->
val pendingTxs = status.value.pendingTransactions
.map(txHistoryItemConverter::convert)
.toPersistentList()
_uiState.update { state ->
if (state is TxHistoryUM.NotSupported) {
state.copy(pendingTransactions = pendingTxs)
} else {
state
}
}
val pending = status.value.pendingTransactions
stateController.updatePendingTransactions(
pendingTxs = {
val converter = TxHistoryItemToTransactionItemUMConverter(
currency = params.currency,
txHistoryUiActions = this,
lookupContext = lookupContext,
)
pending.map(converter::convert).toPersistentList()
},
legacyPendingTxs = { pending.map(legacyTxHistoryItemConverter::convert).toPersistentList() },
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.txhistory.state
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import kotlinx.collections.immutable.ImmutableList
/**
* Snapshot of transaction history items emitted by [TxHistoryListManager]. Wraps either the
* primary or legacy item list so that one [Flow] can carry both pipelines, with the active
* variant chosen via the design feature toggle.
*/
internal sealed interface TxHistoryItemsSnapshot {
data class Items(val items: ImmutableList<TxHistoryItemsUM.TxHistoryItemUM>) : TxHistoryItemsSnapshot
data class LegacyItems(val items: ImmutableList<TxHistoryUM.TxHistoryItemUM>) : TxHistoryItemsSnapshot
}

View file

@ -0,0 +1,182 @@
package com.tangem.features.txhistory.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
/**
* Owns the transaction history UI state and routes updates to either [legacyUiState] or
* [uiState] based on [DesignFeatureToggles.isRedesignEnabled]. Only the active pipeline gets
* emitted to; the inactive flow stays at its initial Loading value.
*/
@ModelScoped
internal class TxHistoryStateController @Inject constructor(
private val designFeatureToggles: DesignFeatureToggles,
) {
private val _legacyUiState: MutableStateFlow<TxHistoryUM> =
MutableStateFlow(TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = {}))
val legacyUiState: StateFlow<TxHistoryUM> = _legacyUiState
private val _uiState: MutableStateFlow<TxHistoryItemsUM> =
MutableStateFlow(TxHistoryItemsUM.Loading(isBalanceHidden = true, onExploreClick = {}))
val uiState: StateFlow<TxHistoryItemsUM> = _uiState
val isNotSupported: Boolean
get() = if (designFeatureToggles.isRedesignEnabled) {
_uiState.value is TxHistoryItemsUM.NotSupported
} else {
_legacyUiState.value is TxHistoryUM.NotSupported
}
fun setLoading(isBalanceHidden: Boolean, onExploreClick: () -> Unit) {
if (designFeatureToggles.isRedesignEnabled) {
_uiState.value = TxHistoryItemsUM.Loading(
isBalanceHidden = isBalanceHidden,
onExploreClick = onExploreClick,
)
} else {
_legacyUiState.value = TxHistoryUM.Loading(
isBalanceHidden = isBalanceHidden,
onExploreClick = onExploreClick,
)
}
}
fun setLoadingIfNotContent(onExploreClick: () -> Unit) {
if (designFeatureToggles.isRedesignEnabled) {
_uiState.update { state ->
state as? TxHistoryItemsUM.Content ?: TxHistoryItemsUM.Loading(state.isBalanceHidden, onExploreClick)
}
} else {
_legacyUiState.update { state ->
state as? TxHistoryUM.Content ?: TxHistoryUM.Loading(state.isBalanceHidden, onExploreClick)
}
}
}
fun setError(onReloadClick: () -> Unit, onExploreClick: () -> Unit) {
if (designFeatureToggles.isRedesignEnabled) {
_uiState.value = TxHistoryItemsUM.Error(
isBalanceHidden = _uiState.value.isBalanceHidden,
onReloadClick = onReloadClick,
onExploreClick = onExploreClick,
)
} else {
_legacyUiState.value = TxHistoryUM.Error(
isBalanceHidden = _legacyUiState.value.isBalanceHidden,
onReloadClick = onReloadClick,
onExploreClick = onExploreClick,
)
}
}
fun setEmpty(onExploreClick: () -> Unit) {
if (designFeatureToggles.isRedesignEnabled) {
_uiState.value = TxHistoryItemsUM.Empty(
isBalanceHidden = _uiState.value.isBalanceHidden,
onExploreClick = onExploreClick,
)
} else {
_legacyUiState.value = TxHistoryUM.Empty(
isBalanceHidden = _legacyUiState.value.isBalanceHidden,
onExploreClick = onExploreClick,
)
}
}
fun setNotSupported(onExploreClick: () -> Unit) {
if (designFeatureToggles.isRedesignEnabled) {
_uiState.value = TxHistoryItemsUM.NotSupported(
isBalanceHidden = _uiState.value.isBalanceHidden,
pendingTransactions = persistentListOf(),
onExploreClick = onExploreClick,
)
} else {
_legacyUiState.value = TxHistoryUM.NotSupported(
isBalanceHidden = _legacyUiState.value.isBalanceHidden,
pendingTransactions = persistentListOf(),
onExploreClick = onExploreClick,
)
}
}
fun setContent(snapshot: TxHistoryItemsSnapshot, loadMore: () -> Boolean) {
when (snapshot) {
is TxHistoryItemsSnapshot.Items -> _uiState.update { state ->
if (state is TxHistoryItemsUM.Content) {
state.copy(items = snapshot.items)
} else {
TxHistoryItemsUM.Content(
items = snapshot.items,
isBalanceHidden = state.isBalanceHidden,
isLoadingMore = false,
loadMore = loadMore,
)
}
}
is TxHistoryItemsSnapshot.LegacyItems -> _legacyUiState.update { state ->
if (state is TxHistoryUM.Content) {
state.copy(items = snapshot.items)
} else {
TxHistoryUM.Content(
items = snapshot.items,
isBalanceHidden = state.isBalanceHidden,
loadMore = loadMore,
)
}
}
}
}
fun updateLoadingMore(isLoadingMore: Boolean) {
if (!designFeatureToggles.isRedesignEnabled) return
_uiState.update { state ->
if (state is TxHistoryItemsUM.Content && state.isLoadingMore != isLoadingMore) {
state.copy(isLoadingMore = isLoadingMore)
} else {
state
}
}
}
fun updateBalanceHidden(isBalanceHidden: Boolean) {
if (designFeatureToggles.isRedesignEnabled) {
_uiState.update { state -> state.copySealed(isBalanceHidden = isBalanceHidden) }
} else {
_legacyUiState.update { state -> state.copySealed(isBalanceHidden = isBalanceHidden) }
}
}
fun updatePendingTransactions(
pendingTxs: () -> ImmutableList<TransactionItemUM>,
legacyPendingTxs: () -> ImmutableList<TransactionState>,
) {
if (designFeatureToggles.isRedesignEnabled) {
_uiState.update { state ->
if (state is TxHistoryItemsUM.NotSupported) {
state.copy(pendingTransactions = pendingTxs())
} else {
state
}
}
} else {
_legacyUiState.update { state ->
if (state is TxHistoryUM.NotSupported) {
state.copy(pendingTransactions = legacyPendingTxs())
} else {
state
}
}
}
}
}

View file

@ -0,0 +1,97 @@
package com.tangem.features.txhistory.utils
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.pagination.Batch
import com.tangem.pagination.PaginationStatus
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
internal class TxHistoryLegacyUiManager(
private val state: MutableStateFlow<TxHistoryListState>,
private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
private val txHistoryUiActions: TxHistoryUiActions,
) {
@OptIn(ExperimentalCoroutinesApi::class)
val items: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = state
.filter { state ->
state.status !is PaginationStatus.None &&
state.status !is PaginationStatus.InitialLoading &&
state.status !is PaginationStatus.InitialLoadingError
}
.mapLatest { state ->
state.legacyUiBatches.asSequence()
.flatMap { it.data }
.toImmutableList()
}
.distinctUntilChanged()
fun createOrUpdateUiBatches(
newCurrencyBatches: List<Batch<Int, PaginationWrapper<TxInfo>>>,
shouldClearUiBatches: Boolean,
): List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> {
val currentUiBatches = state.value.legacyUiBatches
val batches = if (shouldClearUiBatches) mutableListOf() else currentUiBatches.toMutableList()
for ((key, data) in newCurrencyBatches) {
val existingBatchIndex = batches.indexOfFirst { it.key == key }
if (existingBatchIndex == -1) {
val items = generateUiItems(key, data)
batches.add(Batch(key = key, data = items))
} else if (currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data.items)) {
val items = generateUiItems(key, data)
batches[existingBatchIndex] = Batch(key = key, data = items)
}
}
return batches
}
private fun generateUiItems(key: Int, data: PaginationWrapper<TxInfo>): List<TxHistoryUM.TxHistoryItemUM> {
val items = mutableListOf<TxHistoryUM.TxHistoryItemUM>()
if (key == 0) {
items.add(TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = txHistoryUiActions::openExplorer))
}
if (data.items.isNotEmpty()) {
val firstItem = data.items.first()
val firstDate = firstItem.timestampInMillis.toDateFormatWithTodayYesterday()
items.add(
TxHistoryUM.TxHistoryItemUM.GroupTitle(
title = firstDate,
itemKey = "$key-$firstDate",
),
)
items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(firstItem)))
data.items.zipWithNext { current, next ->
val currentDate = current.timestampInMillis.toDateFormatWithTodayYesterday()
val nextDate = next.timestampInMillis.toDateFormatWithTodayYesterday()
if (currentDate != nextDate) {
items.add(
TxHistoryUM.TxHistoryItemUM.GroupTitle(
title = nextDate,
itemKey = "$key-$nextDate",
),
)
}
items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(next)))
}
}
return items
}
private fun List<TxHistoryUM.TxHistoryItemUM>.transactionItemsSizeNotEqual(txInfos: List<TxInfo>): Boolean {
return this.filterIsInstance<TxHistoryUM.TxHistoryItemUM.Transaction>().size != txInfos.size
}
}

View file

@ -1,34 +1,39 @@
package com.tangem.features.txhistory.utils
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import com.tangem.domain.txhistory.model.TxHistoryListConfig
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.state.TxHistoryItemsSnapshot
import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
private typealias TxHistoryBatchAction = BatchAction<Int, TxHistoryListConfig, Nothing>
@Suppress("LongParameterList")
internal class TxHistoryListManager(
private val repository: TxHistoryRepositoryV2,
private val dispatchers: CoroutineDispatcherProvider,
private val userWalletId: UserWalletId,
private val currency: CryptoCurrency,
txHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
txHistoryUiActions: TxHistoryUiActions,
private val designFeatureToggles: DesignFeatureToggles,
private val txHistoryUiActions: TxHistoryUiActions,
private val lookupDataFlow: Flow<TxHistoryLookupContext>,
legacyTxHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
) {
private val jobHolder = JobHolder()
@ -37,13 +42,18 @@ internal class TxHistoryListManager(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val state: MutableStateFlow<TxHistoryListState> = MutableStateFlow(TxHistoryListState())
private val uiManager = TxHistoryUiManager(
private val uiManager = TxHistoryUiManager(state = state)
private val legacyUiManager = TxHistoryLegacyUiManager(
state = state,
txHistoryItemConverter = txHistoryItemConverter,
txHistoryItemConverter = legacyTxHistoryItemConverter,
txHistoryUiActions = txHistoryUiActions,
)
val uiItems: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = uiManager.items
val uiItems: Flow<TxHistoryItemsSnapshot> = if (designFeatureToggles.isRedesignEnabled) {
uiManager.items.map(TxHistoryItemsSnapshot::Items)
} else {
legacyUiManager.items.map(TxHistoryItemsSnapshot::LegacyItems)
}
val paginationStatus: Flow<PaginationStatus<*>> = state.map { it.status }.distinctUntilChanged()
suspend fun init() = coroutineScope {
@ -55,11 +65,24 @@ internal class TxHistoryListManager(
batchSize = 50,
)
batchFlow.state
.onEach { state -> updateState(state) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
if (designFeatureToggles.isRedesignEnabled) {
var previousLookup: TxHistoryLookupContext? = null
combine(batchFlow.state, lookupDataFlow) { batchState, lookup -> batchState to lookup }
.onEach { (batchState, lookup) ->
val isLookupChanged = previousLookup != null && previousLookup != lookup
previousLookup = lookup
updateState(batchState, lookup, isLookupChanged)
}
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
} else {
batchFlow.state
.onEach { batchState -> updateState(batchState, lookupContext = null, isLookupChanged = false) }
.flowOn(dispatchers.default)
.launchIn(scope = this)
.saveIn(jobHolder)
}
}
suspend fun startLoading() {
@ -86,16 +109,40 @@ internal class TxHistoryListManager(
)
}
private fun updateState(batchListState: BatchListState<Int, PaginationWrapper<TxInfo>>) {
private fun updateState(
batchListState: BatchListState<Int, PaginationWrapper<TxInfo>>,
lookupContext: TxHistoryLookupContext?,
isLookupChanged: Boolean,
) {
state.update { state ->
val shouldClearUiBatches =
state.status is PaginationStatus.InitialLoading && batchListState.status is PaginationStatus.Paginating
val isInitialToPaginating = state.status is PaginationStatus.InitialLoading &&
batchListState.status is PaginationStatus.Paginating
val shouldClearUiBatches = isInitialToPaginating || isLookupChanged
val isRedesignEnabled = designFeatureToggles.isRedesignEnabled
state.copy(
status = batchListState.status,
uiBatches = uiManager.createOrUpdateUiBatches(
newCurrencyBatches = batchListState.data,
shouldClearUiBatches = shouldClearUiBatches,
),
uiBatches = if (isRedesignEnabled) {
val converter = TxHistoryItemToTransactionItemUMConverter(
currency = currency,
txHistoryUiActions = txHistoryUiActions,
lookupContext = lookupContext,
)
uiManager.createOrUpdateUiBatches(
newCurrencyBatches = batchListState.data,
shouldClearUiBatches = shouldClearUiBatches,
converter = converter,
)
} else {
state.uiBatches
},
legacyUiBatches = if (isRedesignEnabled) {
state.legacyUiBatches
} else {
legacyUiManager.createOrUpdateUiBatches(
newCurrencyBatches = batchListState.data,
shouldClearUiBatches = shouldClearUiBatches,
)
},
)
}
}

View file

@ -1,10 +1,12 @@
package com.tangem.features.txhistory.utils
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.pagination.Batch
import com.tangem.pagination.PaginationStatus
data class TxHistoryListState(
val status: PaginationStatus<*> = PaginationStatus.None,
val uiBatches: List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> = emptyList(),
val uiBatches: List<Batch<Int, List<TxHistoryItemsUM.TxHistoryItemUM>>> = emptyList(),
val legacyUiBatches: List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> = emptyList(),
)

View file

@ -3,30 +3,22 @@ package com.tangem.features.txhistory.utils
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.pagination.Batch
import com.tangem.pagination.PaginationStatus
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import java.util.UUID
internal class TxHistoryUiManager(
private val state: MutableStateFlow<TxHistoryListState>,
private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
private val txHistoryUiActions: TxHistoryUiActions,
) {
@OptIn(ExperimentalCoroutinesApi::class)
val items: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = state
// filter initial states, since we dont emit loading items as UI items
.filter { state ->
state.status !is PaginationStatus.None &&
state.status !is PaginationStatus.InitialLoading &&
state.status !is PaginationStatus.InitialLoadingError
}
val items: Flow<ImmutableList<TxHistoryItemsUM.TxHistoryItemUM>> = state
.filter { it.hasContent }
.mapLatest { state ->
state.uiBatches.asSequence()
.flatMap { it.data }
@ -37,79 +29,69 @@ internal class TxHistoryUiManager(
fun createOrUpdateUiBatches(
newCurrencyBatches: List<Batch<Int, PaginationWrapper<TxInfo>>>,
shouldClearUiBatches: Boolean,
): List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> {
converter: TxHistoryItemToTransactionItemUMConverter,
): List<Batch<Int, List<TxHistoryItemsUM.TxHistoryItemUM>>> {
val currentUiBatches = state.value.uiBatches
val batches = if (shouldClearUiBatches) mutableListOf() else currentUiBatches.toMutableList()
for ((key, data) in newCurrencyBatches) {
// Find if batch with same key exists
val existingBatchIndex = batches.indexOfFirst { it.key == key }
val shouldUpdateExisting = existingBatchIndex != -1 &&
currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data.items)
// Case 1: Update existing batch if sizes differ
if (shouldUpdateExisting) {
val items = generateUiItems(key, data)
if (existingBatchIndex == -1) {
val items = generateUiItems(key, data, converter)
batches.add(Batch(key = key, data = items))
} else if (currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data.items)) {
val items = generateUiItems(key, data, converter)
batches[existingBatchIndex] = Batch(key = key, data = items)
continue
}
// Case 2: Skip if batch exists and has same size
if (existingBatchIndex != -1) {
continue
}
// Case 3: Create new batch
val items = generateUiItems(key, data)
batches.add(Batch(key = key, data = items))
}
return batches
}
private fun generateUiItems(key: Int, data: PaginationWrapper<TxInfo>): List<TxHistoryUM.TxHistoryItemUM> {
val items = mutableListOf<TxHistoryUM.TxHistoryItemUM>()
private fun generateUiItems(
key: Int,
data: PaginationWrapper<TxInfo>,
converter: TxHistoryItemToTransactionItemUMConverter,
): List<TxHistoryItemsUM.TxHistoryItemUM> {
val items = mutableListOf<TxHistoryItemsUM.TxHistoryItemUM>()
// Add title for the first batch
if (key == 0) {
items.add(TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = txHistoryUiActions::openExplorer))
}
// Process batch items only if there are any
if (data.items.isNotEmpty()) {
// Add first item with its group title
val firstItem = data.items.first()
val firstDate = firstItem.timestampInMillis.toDateFormatWithTodayYesterday()
items.add(
TxHistoryUM.TxHistoryItemUM.GroupTitle(
TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(
title = firstDate,
itemKey = UUID.randomUUID().toString(),
itemKey = "$key-$firstDate",
),
)
items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(firstItem)))
items.add(TxHistoryItemsUM.TxHistoryItemUM.Transaction(converter.convert(firstItem)))
// Process remaining items with date separators when needed
data.items.zipWithNext { current, next ->
val currentDate = current.timestampInMillis.toDateFormatWithTodayYesterday()
val nextDate = next.timestampInMillis.toDateFormatWithTodayYesterday()
if (currentDate != nextDate) {
items.add(
TxHistoryUM.TxHistoryItemUM.GroupTitle(
TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(
title = nextDate,
itemKey = UUID.randomUUID().toString(),
itemKey = "$key-$nextDate",
),
)
}
items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(next)))
items.add(TxHistoryItemsUM.TxHistoryItemUM.Transaction(converter.convert(next)))
}
}
return items
}
private fun List<TxHistoryUM.TxHistoryItemUM>.transactionItemsSizeNotEqual(txInfos: List<TxInfo>): Boolean {
return this.filterIsInstance<TxHistoryUM.TxHistoryItemUM.Transaction>().size != txInfos.size
private fun List<TxHistoryItemsUM.TxHistoryItemUM>.transactionItemsSizeNotEqual(txInfos: List<TxInfo>): Boolean {
return this.filterIsInstance<TxHistoryItemsUM.TxHistoryItemUM.Transaction>().size != txInfos.size
}
}
}
private val TxHistoryListState.hasContent: Boolean
get() = status !is PaginationStatus.None &&
status !is PaginationStatus.InitialLoading &&
status !is PaginationStatus.InitialLoadingError

View file

@ -0,0 +1,767 @@
package com.tangem.features.txhistory.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.Account.CryptoPortfolio.Companion.createMainAccount
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.model.WalletInfo
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.StringsSigns
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryItemToTransactionItemUMConverterTest {
private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true)
private val coin: CryptoCurrency.Coin = createCoin(symbol = "ETH", decimals = 18)
private val token: CryptoCurrency.Token = createToken(symbol = "USDT", decimals = 6)
private val coinConverter
get() = TxHistoryItemToTransactionItemUMConverter(
currency = coin,
txHistoryUiActions = txHistoryUiActions,
)
private val tokenConverter
get() = TxHistoryItemToTransactionItemUMConverter(
currency = token,
txHistoryUiActions = txHistoryUiActions,
)
// region Pill dispatch routing
@Test
fun `GIVEN Pill TransactionType WHEN convert THEN result is Pill with expected kind`() {
val cases = listOf(
TransactionType.Approve to TransactionItemUM.PillKind.APPROVE,
TransactionType.Staking.Stake to TransactionItemUM.PillKind.STAKING,
TransactionType.Staking.Unstake to TransactionItemUM.PillKind.STAKING,
TransactionType.Staking.Restake to TransactionItemUM.PillKind.STAKING,
TransactionType.Staking.Vote(validatorAddress = "0xv") to TransactionItemUM.PillKind.STAKING,
TransactionType.Staking.Withdraw to TransactionItemUM.PillKind.STAKING,
TransactionType.YieldSupply.Enter(address = USER_ADDRESS) to TransactionItemUM.PillKind.YIELD_MODE,
TransactionType.YieldSupply.Exit(address = USER_ADDRESS) to TransactionItemUM.PillKind.YIELD_MODE,
)
cases.forEach { (type, expectedKind) ->
val tx = txInfo(type = type)
val result = coinConverter.convert(tx)
assertThat(result).isInstanceOf(TransactionItemUM.Pill::class.java)
assertThat((result as TransactionItemUM.Pill).kind).isEqualTo(expectedKind)
}
}
// endregion
// region Content — basic types
@Test
fun `GIVEN Operation WHEN convert THEN Content with type name as title`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint NFT"),
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(TextReference.Str("Mint NFT"))
assertThat(result.iconRes).isEqualTo(R.drawable.ic_arrow_down_24)
}
@Test
fun `GIVEN Swap confirmed WHEN convert THEN Content with swapped title`() {
val tx = txInfo(
type = TransactionType.Swap,
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_swapped))
}
@Test
fun `GIVEN Swap unconfirmed WHEN convert THEN Content with swapping title`() {
val tx = txInfo(
type = TransactionType.Swap,
status = TxInfo.TransactionStatus.Unconfirmed,
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_swapping))
}
@Test
fun `GIVEN Swap failed WHEN convert THEN Content with composed failed title and close icon`() {
val tx = txInfo(
type = TransactionType.Swap,
status = TxInfo.TransactionStatus.Failed,
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(
resRef(R.string.common_action_failed, listOf(resRef(R.string.common_swapping))),
)
assertThat(result.iconRes).isEqualTo(R.drawable.ic_close_24)
}
@Test
fun `GIVEN UnknownOperation WHEN convert THEN Content with operation title`() {
val tx = txInfo(
type = TransactionType.UnknownOperation,
interactionAddressType = null,
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.transaction_history_operation))
assertThat(result.subtitle).isEqualTo(ContentSubtitle.Plain(TextReference.EMPTY))
}
@Test
fun `GIVEN GaslessFee WHEN convert THEN Content with gasless fee title`() {
val tx = txInfo(
type = TransactionType.GaslessFee,
interactionAddressType = null,
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.gasless_transaction_fee))
}
@Test
fun `GIVEN ClaimRewards confirmed WHEN convert THEN Content with reward title and no amount sign`() {
val tx = txInfo(
type = TransactionType.Staking.ClaimRewards,
isOutgoing = false,
amount = BigDecimal("2.5"),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.transaction_history_staking_reward))
assertThat(result.subtitle).isEqualTo(
ContentSubtitle.Plain(resRef(R.string.transaction_history_earned_from_stake)),
)
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
}
@Test
fun `GIVEN ClaimRewards unconfirmed WHEN convert THEN Content with claiming title`() {
val tx = txInfo(
type = TransactionType.Staking.ClaimRewards,
status = TxInfo.TransactionStatus.Unconfirmed,
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.transaction_history_claiming_reward))
}
// endregion
// region Content — Transfer
@Test
fun `GIVEN outgoing Transfer confirmed to external address WHEN convert THEN sent title and ExternalAddress subtitle`() {
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.OUTGOING)
assertThat(result.iconRes).isEqualTo(R.drawable.ic_arrow_up_24)
val subtitle = result.subtitle as ContentSubtitle.ExternalAddress
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO)
assertThat(subtitle.rawAddress).isEqualTo(USER_ADDRESS)
assertThat(subtitle.briefAddress).isEqualTo(USER_ADDRESS_BRIEF)
}
@Test
fun `GIVEN outgoing Transfer unconfirmed WHEN convert THEN sending title`() {
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
status = TxInfo.TransactionStatus.Unconfirmed,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_sending))
}
@Test
fun `GIVEN outgoing Transfer failed WHEN convert THEN composed failed title`() {
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
status = TxInfo.TransactionStatus.Failed,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(
resRef(R.string.common_action_failed, listOf(resRef(R.string.common_sending))),
)
}
@Test
fun `GIVEN incoming Transfer confirmed WHEN convert THEN received title and FROM subtitle`() {
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = false,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_received))
assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.INCOMING)
assertThat(result.iconRes).isEqualTo(R.drawable.ic_arrow_down_24)
val subtitle = result.subtitle as ContentSubtitle.ExternalAddress
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM)
}
@Test
fun `GIVEN incoming Transfer unconfirmed WHEN convert THEN receiving title`() {
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = false,
status = TxInfo.TransactionStatus.Unconfirmed,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_receiving))
}
@Test
fun `GIVEN Transfer with own account in accounts mode WHEN convert THEN OwnAccount subtitle and transferred title`() {
val ownAccount = createMainAccount(UserWalletId(stringValue = "00"))
val converter = TxHistoryItemToTransactionItemUMConverter(
currency = coin,
txHistoryUiActions = txHistoryUiActions,
lookupContext = TxHistoryLookupContext(
ownAccountByAddress = mapOf(USER_ADDRESS to ownAccount),
isAccountsModeEnabled = true,
walletInfoById = emptyMap(),
),
)
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = converter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_transferred))
val subtitle = result.subtitle as ContentSubtitle.OwnAccount
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO)
assertThat(subtitle.iconResId).isNotEqualTo(0)
}
@Test
fun `GIVEN Transfer with own account in wallets mode WHEN convert THEN OwnWallet subtitle`() {
val userWalletId = UserWalletId(stringValue = "01")
val ownAccount = createMainAccount(userWalletId)
val walletInfo = WalletInfo(name = "Main wallet", deviceIconUM = DeviceIconUM.Mobile)
val converter = TxHistoryItemToTransactionItemUMConverter(
currency = coin,
txHistoryUiActions = txHistoryUiActions,
lookupContext = TxHistoryLookupContext(
ownAccountByAddress = mapOf(USER_ADDRESS to ownAccount),
isAccountsModeEnabled = false,
walletInfoById = mapOf(userWalletId to walletInfo),
),
)
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = false,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = converter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_transferred))
val subtitle = result.subtitle as ContentSubtitle.OwnWallet
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM)
assertThat(subtitle.walletName).isEqualTo("Main wallet")
assertThat(subtitle.deviceIconUM).isEqualTo(DeviceIconUM.Mobile)
}
@Test
fun `GIVEN Transfer with own account but missing wallet info in wallets mode WHEN convert THEN ExternalAddress subtitle`() {
val ownAccount = createMainAccount(UserWalletId(stringValue = "02"))
val converter = TxHistoryItemToTransactionItemUMConverter(
currency = coin,
txHistoryUiActions = txHistoryUiActions,
lookupContext = TxHistoryLookupContext(
ownAccountByAddress = mapOf(USER_ADDRESS to ownAccount),
isAccountsModeEnabled = false,
walletInfoById = emptyMap(),
),
)
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = converter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
assertThat(result.subtitle).isInstanceOf(ContentSubtitle.ExternalAddress::class.java)
}
@Test
fun `GIVEN Transfer with non-User interaction WHEN convert THEN Plain subtitle`() {
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.subtitle).isInstanceOf(ContentSubtitle.Plain::class.java)
assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
}
// endregion
// region Content — YieldSupply
@Test
fun `GIVEN YieldSupply Topup WHEN convert THEN topup title`() {
val tx = txInfo(type = TransactionType.YieldSupply.Topup)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_topup))
}
@Test
fun `GIVEN YieldSupply Send Coin not withdraw and incoming WHEN convert THEN transfer title`() {
val tx = txInfo(
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = false),
isOutgoing = false,
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.common_transfer))
}
@Test
fun `GIVEN YieldSupply Send Coin withdraw WHEN convert THEN withdraw title`() {
val tx = txInfo(
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = true),
isOutgoing = false,
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_withdraw))
}
@Test
fun `GIVEN YieldSupply Send outgoing WHEN convert THEN withdraw title`() {
val tx = txInfo(
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = false),
isOutgoing = true,
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_withdraw))
}
@Test
fun `GIVEN YieldSupply Send Token incoming WHEN convert THEN amount and symbol hidden`() {
val tx = txInfo(
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = false),
isOutgoing = false,
)
val result = tokenConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount).isEmpty()
assertThat(result.currencySymbol).isEmpty()
}
@Test
fun `GIVEN YieldSupply Send Token outgoing WHEN convert THEN amount and symbol shown`() {
val tx = txInfo(
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = true),
isOutgoing = true,
)
val result = tokenConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount).isNotEmpty()
assertThat(result.currencySymbol).isEqualTo("USDT")
}
@Test
fun `GIVEN YieldSupply DeployContract WHEN convert THEN deploy title and doc icon`() {
val tx = txInfo(type = TransactionType.YieldSupply.DeployContract(address = USER_ADDRESS))
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_deploy_contract))
assertThat(result.iconRes).isEqualTo(R.drawable.ic_doc_24)
}
@Test
fun `GIVEN YieldSupply InitializeToken WHEN convert THEN initialize title and gear icon`() {
val tx = txInfo(type = TransactionType.YieldSupply.InitializeToken(address = USER_ADDRESS))
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_initialize))
assertThat(result.iconRes).isEqualTo(R.drawable.ic_gear_24)
}
@Test
fun `GIVEN YieldSupply ReactivateToken WHEN convert THEN reactivate title and refresh icon`() {
val tx = txInfo(type = TransactionType.YieldSupply.ReactivateToken(address = USER_ADDRESS))
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_reactivate))
assertThat(result.iconRes).isEqualTo(R.drawable.ic_refresh_24)
}
@Test
fun `GIVEN YieldSupply Topup Token WHEN convert THEN amount-formatted topup subtitle`() {
val tx = txInfo(type = TransactionType.YieldSupply.Topup, amount = BigDecimal("3.0"))
val result = tokenConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain
val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.yield_module_transaction_topup_subtitle)
}
@Test
fun `GIVEN YieldSupply Send Token withdraw incoming WHEN convert THEN exit subtitle`() {
val tx = txInfo(
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = true),
isOutgoing = false,
)
val result = tokenConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain
val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.yield_module_transaction_exit_subtitle)
}
@Test
fun `GIVEN YieldSupply Topup Coin WHEN convert THEN address-based subtitle`() {
val tx = txInfo(
type = TransactionType.YieldSupply.Topup,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
isOutgoing = true,
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain
val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_for_address)
}
// endregion
// region Amount formatting
@Test
fun `GIVEN outgoing confirmed WHEN convert THEN amount has minus prefix`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
isOutgoing = true,
amount = BigDecimal("1.5"),
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isTrue()
}
@Test
fun `GIVEN incoming confirmed WHEN convert THEN amount has plus prefix`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
isOutgoing = false,
amount = BigDecimal("1.5"),
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isTrue()
}
@Test
fun `GIVEN failed Operation WHEN convert THEN amount has no sign prefix`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
isOutgoing = true,
status = TxInfo.TransactionStatus.Failed,
amount = BigDecimal("1.5"),
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
}
@Test
fun `GIVEN zero amount Operation WHEN convert THEN amount has no sign prefix`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
isOutgoing = true,
amount = BigDecimal.ZERO,
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
}
// endregion
// region Address subtitle resolution
@Test
fun `GIVEN Operation with Contract interaction WHEN convert THEN contract address subtitle`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain
val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.transaction_history_contract_address)
}
@Test
fun `GIVEN Operation with Multiple interaction outgoing WHEN convert THEN to-address subtitle`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.Multiple(
addresses = listOf(USER_ADDRESS, "0xother"),
),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain
val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_to_address)
}
@Test
fun `GIVEN Operation with Multiple interaction incoming WHEN convert THEN from-address subtitle`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
isOutgoing = false,
interactionAddressType = TxInfo.InteractionAddressType.Multiple(
addresses = listOf(USER_ADDRESS),
),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain
val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_from_address)
}
@Test
fun `GIVEN Operation with Validator interaction WHEN convert THEN validator subtitle`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
interactionAddressType = TxInfo.InteractionAddressType.Validator(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain
val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_validator)
}
@Test
fun `GIVEN Operation with null interaction WHEN convert THEN empty subtitle`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
interactionAddressType = null,
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.subtitle).isEqualTo(ContentSubtitle.Plain(TextReference.EMPTY))
}
// endregion
// region Misc
@Test
fun `GIVEN failed Transfer WHEN convert THEN icon overridden to close`() {
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
status = TxInfo.TransactionStatus.Failed,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.iconRes).isEqualTo(R.drawable.ic_close_24)
}
@Test
fun `GIVEN any Content WHEN onClick invoked THEN openTxInExplorer called with txHash`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
result.onClick()
verify { txHistoryUiActions.openTxInExplorer(TX_HASH) }
}
@Test
fun `GIVEN tx WHEN convert THEN txHash and timestamp propagated`() {
val tx = txInfo(
type = TransactionType.Operation(name = "Mint"),
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.txHash).isEqualTo(TX_HASH)
assertThat(result.timestamp).isEqualTo(TIMESTAMP)
}
// endregion
// region Helpers
private fun txInfo(
type: TransactionType,
status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed,
isOutgoing: Boolean = false,
amount: BigDecimal = BigDecimal.ONE,
interactionAddressType: TxInfo.InteractionAddressType? = null,
): TxInfo = TxInfo(
txHash = TX_HASH,
timestampInMillis = TIMESTAMP,
isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
interactionAddressType = interactionAddressType,
status = status,
type = type,
amount = amount,
)
private fun resRef(id: Int): TextReference = TextReference.Res(id = id)
private fun resRef(id: Int, args: List<Any>): TextReference = TextReference.Res(
id = id,
formatArgs = com.tangem.core.ui.extensions.WrappedList(args),
)
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
suffix = CryptoCurrency.ID.Suffix.RawID(rawId = "ethereum"),
),
network = createNetwork(symbol = symbol, canHandleTokens = true),
name = "Ethereum",
symbol = symbol,
decimals = decimals,
iconUrl = null,
isCustom = false,
)
private fun createToken(symbol: String, decimals: Int): CryptoCurrency.Token = CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = TOKEN_CONTRACT),
),
network = createNetwork(symbol = "ETH", canHandleTokens = true),
name = "Tether USD",
symbol = symbol,
decimals = decimals,
iconUrl = null,
isCustom = false,
contractAddress = TOKEN_CONTRACT,
)
private fun createNetwork(symbol: String, canHandleTokens: Boolean): Network = Network(
id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None),
name = "Ethereum",
currencySymbol = symbol,
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = true,
canHandleTokens = canHandleTokens,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
private companion object {
const val TX_HASH = "0xtxhash"
const val TIMESTAMP = 1_700_000_000_000L
const val USER_ADDRESS = "0x1234567890abcdef1234"
const val USER_ADDRESS_BRIEF = "0x1234...1234"
const val TOKEN_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
}
// endregion
}

View file

@ -0,0 +1,298 @@
package com.tangem.features.txhistory.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.converter.TxHistoryStatusPillConverter.Input
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryStatusPillConverterTest {
private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true)
private val coin = createCoin(symbol = "ETH", decimals = 18)
private val converter = TxHistoryStatusPillConverter(coin, txHistoryUiActions)
// region Approve
@Test
fun `GIVEN Approve uiStatus Confirmed with User address WHEN convert THEN approved label and address subtitle`() {
val tx = txInfo(
type = TransactionType.Approve,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = converter.convert(Input(tx, Status.Confirmed, ApproveSpec))
assertThat(result.kind).isEqualTo(TransactionItemUM.PillKind.APPROVE)
assertThat(result.status).isEqualTo(Status.Confirmed)
assertThat(result.label).isEqualTo(resRef(R.string.common_approved))
assertThat(result.amount).isNotNull()
assertThat(result.currencySymbol).isEqualTo("ETH")
val subtitle = result.subtitle as TransactionItemUM.PillSubtitle.Address
assertThat(subtitle.rawAddress).isEqualTo(USER_ADDRESS)
assertThat(subtitle.briefAddress).isEqualTo(USER_ADDRESS_BRIEF)
}
@Test
fun `GIVEN Approve uiStatus Unconfirmed WHEN convert THEN approving label`() {
val tx = txInfo(
type = TransactionType.Approve,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = converter.convert(Input(tx, Status.Unconfirmed, ApproveSpec))
assertThat(result.label).isEqualTo(resRef(R.string.common_approving))
}
@Test
fun `GIVEN Approve uiStatus Failed WHEN convert THEN non-composed approving label and no subtitle`() {
val tx = txInfo(
type = TransactionType.Approve,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val result = converter.convert(Input(tx, Status.Failed, ApproveSpec))
assertThat(result.label).isEqualTo(resRef(R.string.common_approving))
assertThat(result.subtitle).isNull()
}
@Test
fun `GIVEN Approve uiStatus Confirmed without User interaction address WHEN convert THEN no subtitle`() {
val tx = txInfo(
type = TransactionType.Approve,
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
)
val result = converter.convert(Input(tx, Status.Confirmed, ApproveSpec))
assertThat(result.subtitle).isNull()
}
// endregion
// region Staking
@Test
fun `GIVEN Stake uiStatus Confirmed WHEN convert THEN staked label and amount`() {
val tx = txInfo(type = TransactionType.Staking.Stake, amount = BigDecimal("1.5"))
val result = converter.convert(Input(tx, Status.Confirmed, StakeSpec))
assertThat(result.kind).isEqualTo(TransactionItemUM.PillKind.STAKING)
assertThat(result.label).isEqualTo(resRef(R.string.common_staked))
assertThat(result.amount).isNotNull()
assertThat(result.currencySymbol).isEqualTo("ETH")
}
@Test
fun `GIVEN Stake uiStatus Failed WHEN convert THEN composed failed label and no amount`() {
val tx = txInfo(type = TransactionType.Staking.Stake)
val result = converter.convert(Input(tx, Status.Failed, StakeSpec))
assertThat(result.label).isEqualTo(
resRef(R.string.common_action_failed, listOf(resRef(R.string.common_staking))),
)
assertThat(result.amount).isNull()
assertThat(result.currencySymbol).isNull()
}
@Test
fun `GIVEN Unstake uiStatus Confirmed WHEN convert THEN unstaked label`() {
val tx = txInfo(type = TransactionType.Staking.Unstake)
val result = converter.convert(Input(tx, Status.Confirmed, UnstakeSpec))
assertThat(result.label).isEqualTo(resRef(R.string.staking_unstaked))
}
@Test
fun `GIVEN Restake uiStatus Confirmed WHEN convert THEN restaked label`() {
val tx = txInfo(type = TransactionType.Staking.Restake)
val result = converter.convert(Input(tx, Status.Confirmed, RestakeSpec))
assertThat(result.label).isEqualTo(resRef(R.string.transaction_history_rewards_restaked))
}
@Test
fun `GIVEN Vote uiStatus Confirmed WHEN convert THEN vote label and no amount`() {
val tx = txInfo(type = TransactionType.Staking.Vote(validatorAddress = "0xv"))
val result = converter.convert(Input(tx, Status.Confirmed, VoteSpec))
assertThat(result.label).isEqualTo(resRef(R.string.staking_vote))
assertThat(result.amount).isNull()
}
@Test
fun `GIVEN Vote uiStatus Failed WHEN convert THEN composed failed vote label`() {
val tx = txInfo(type = TransactionType.Staking.Vote(validatorAddress = "0xv"))
val result = converter.convert(Input(tx, Status.Failed, VoteSpec))
assertThat(result.label).isEqualTo(
resRef(R.string.common_action_failed, listOf(resRef(R.string.staking_vote))),
)
}
@Test
fun `GIVEN Withdraw uiStatus Confirmed WHEN convert THEN withdraw label and no amount`() {
val tx = txInfo(type = TransactionType.Staking.Withdraw)
val result = converter.convert(Input(tx, Status.Confirmed, WithdrawSpec))
assertThat(result.label).isEqualTo(resRef(R.string.staking_withdraw))
assertThat(result.amount).isNull()
}
// endregion
// region YieldSupply
@Test
fun `GIVEN YieldEnter uiStatus Confirmed WHEN convert THEN enter label and no amount`() {
val tx = txInfo(type = TransactionType.YieldSupply.Enter(address = USER_ADDRESS))
val result = converter.convert(Input(tx, Status.Confirmed, YieldEnterSpec))
assertThat(result.kind).isEqualTo(TransactionItemUM.PillKind.YIELD_MODE)
assertThat(result.label).isEqualTo(resRef(R.string.yield_module_transaction_enter))
assertThat(result.amount).isNull()
}
@Test
fun `GIVEN YieldEnter uiStatus Failed WHEN convert THEN composed failed yield mode label`() {
val tx = txInfo(type = TransactionType.YieldSupply.Enter(address = USER_ADDRESS))
val result = converter.convert(Input(tx, Status.Failed, YieldEnterSpec))
assertThat(result.label).isEqualTo(
resRef(R.string.common_action_failed, listOf(resRef(R.string.common_yield_mode))),
)
}
@Test
fun `GIVEN YieldExit uiStatus Confirmed WHEN convert THEN exit label`() {
val tx = txInfo(type = TransactionType.YieldSupply.Exit(address = USER_ADDRESS))
val result = converter.convert(Input(tx, Status.Confirmed, YieldExitSpec))
assertThat(result.label).isEqualTo(resRef(R.string.yield_module_transaction_exit))
}
@Test
fun `GIVEN YieldExit uiStatus Failed WHEN convert THEN composed failed label`() {
val tx = txInfo(type = TransactionType.YieldSupply.Exit(address = USER_ADDRESS))
val result = converter.convert(Input(tx, Status.Failed, YieldExitSpec))
assertThat(result.label).isEqualTo(
resRef(
R.string.common_action_failed,
listOf(resRef(R.string.transaction_history_disabling_yield_mode)),
),
)
}
// endregion
// region Misc
@Test
fun `GIVEN any Pill WHEN onClick invoked THEN openTxInExplorer called with txHash`() {
val tx = txInfo(type = TransactionType.Staking.Stake)
val result = converter.convert(Input(tx, Status.Confirmed, StakeSpec))
result.onClick()
verify { txHistoryUiActions.openTxInExplorer(TX_HASH) }
}
@Test
fun `GIVEN tx WHEN convert THEN txHash and timestamp propagated`() {
val tx = txInfo(type = TransactionType.Staking.Stake)
val result = converter.convert(Input(tx, Status.Confirmed, StakeSpec))
assertThat(result.txHash).isEqualTo(TX_HASH)
assertThat(result.timestamp).isEqualTo(TIMESTAMP)
}
// endregion
// region Helpers
private fun txInfo(
type: TransactionType,
amount: BigDecimal = BigDecimal.ONE,
interactionAddressType: TxInfo.InteractionAddressType? = null,
): TxInfo = TxInfo(
txHash = TX_HASH,
timestampInMillis = TIMESTAMP,
isOutgoing = false,
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
interactionAddressType = interactionAddressType,
status = TxInfo.TransactionStatus.Confirmed,
type = type,
amount = amount,
)
private fun resRef(id: Int): TextReference = TextReference.Res(id = id)
private fun resRef(id: Int, args: List<Any>): TextReference = TextReference.Res(
id = id,
formatArgs = com.tangem.core.ui.extensions.WrappedList(args),
)
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
suffix = CryptoCurrency.ID.Suffix.RawID(rawId = "ethereum"),
),
network = createNetwork(symbol = symbol),
name = "Ethereum",
symbol = symbol,
decimals = decimals,
iconUrl = null,
isCustom = false,
)
private fun createNetwork(symbol: String): Network = Network(
id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None),
name = "Ethereum",
currencySymbol = symbol,
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
private companion object {
const val TX_HASH = "0xtxhash"
const val TIMESTAMP = 1_700_000_000_000L
const val USER_ADDRESS = "0x1234567890abcdef1234"
const val USER_ADDRESS_BRIEF = "0x1234...1234"
}
// endregion
}