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
}