Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-25 14:44:18 +03:00
parent 51e650c360
commit 391e0562cc
13 changed files with 519 additions and 115 deletions

View file

@ -40,6 +40,8 @@ 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.components.transactions.state.TxIcon
import com.tangem.core.ui.components.transactions.state.asImageVector
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
@ -50,6 +52,12 @@ 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
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_up_20
import com.tangem.core.ui.res.generated.icons.ic_card_20
import com.tangem.core.ui.res.generated.icons.ic_cross_20
import com.tangem.core.ui.test.TransactionHistoryItemTestTags
@Composable
@ -86,7 +94,7 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
),
) {
StatusCircle(
iconRes = state.iconRes,
icon = state.icon,
status = state.status,
modifier = Modifier
.layoutId(TangemRowLayoutId.HEAD)
@ -166,7 +174,7 @@ private fun WarningLine(warning: TextReference, modifier: Modifier = Modifier) {
// region Status circle
@Composable
private fun StatusCircle(iconRes: Int, status: Status, modifier: Modifier = Modifier) {
private fun StatusCircle(icon: TxIcon, status: Status, modifier: Modifier = Modifier) {
Box(
modifier = modifier.background(
color = status.backgroundColor,
@ -174,7 +182,7 @@ private fun StatusCircle(iconRes: Int, status: Status, modifier: Modifier = Modi
),
) {
Icon(
painter = painterResource(iconRes),
imageVector = icon.asImageVector(),
contentDescription = null,
tint = status.iconTint,
modifier = Modifier
@ -396,7 +404,7 @@ private fun String.stripLeadingSign(): String = when {
@Suppress("LongParameterList")
private fun previewContent(
txHash: String,
iconRes: Int,
icon: TxIcon,
direction: Direction,
status: Status,
title: String,
@ -411,7 +419,7 @@ private fun previewContent(
status = status,
direction = direction,
onClick = {},
iconRes = iconRes,
icon = icon,
title = stringReference(title),
subtitle = ContentSubtitle.Plain(stringReference(subtitle)),
timestamp = 0L,
@ -438,7 +446,7 @@ private fun Preview_TransactionItem_Receive() {
items = listOf(
previewContent(
txHash = "rcv-c",
iconRes = R.drawable.ic_arrow_down_24,
icon = TxIcon.Vector(Icons.ic_arrow_down_20),
direction = Direction.INCOMING,
status = Status.Confirmed,
title = "Received",
@ -447,7 +455,7 @@ private fun Preview_TransactionItem_Receive() {
),
previewContent(
txHash = "rcv-u",
iconRes = R.drawable.ic_arrow_down_24,
icon = TxIcon.Vector(Icons.ic_arrow_down_20),
direction = Direction.INCOMING,
status = Status.Unconfirmed,
title = "Receiving",
@ -456,7 +464,7 @@ private fun Preview_TransactionItem_Receive() {
),
previewContent(
txHash = "rcv-f",
iconRes = R.drawable.ic_close_24,
icon = TxIcon.Vector(Icons.ic_cross_20),
direction = Direction.INCOMING,
status = Status.Failed,
title = "Receiving failed",
@ -477,7 +485,7 @@ private fun Preview_TransactionItem_Send() {
items = listOf(
previewContent(
txHash = "snd-c",
iconRes = R.drawable.ic_arrow_up_24,
icon = TxIcon.Vector(Icons.ic_arrow_up_20),
direction = Direction.OUTGOING,
status = Status.Confirmed,
title = "Sent",
@ -486,7 +494,7 @@ private fun Preview_TransactionItem_Send() {
),
previewContent(
txHash = "snd-u",
iconRes = R.drawable.ic_arrow_up_24,
icon = TxIcon.Vector(Icons.ic_arrow_up_20),
direction = Direction.OUTGOING,
status = Status.Unconfirmed,
title = "Sending",
@ -495,7 +503,7 @@ private fun Preview_TransactionItem_Send() {
),
previewContent(
txHash = "snd-f",
iconRes = R.drawable.ic_close_24,
icon = TxIcon.Vector(Icons.ic_cross_20),
direction = Direction.OUTGOING,
status = Status.Failed,
title = "Sending failed",
@ -516,7 +524,7 @@ private fun Preview_TransactionItem_Swap() {
items = listOf(
previewContent(
txHash = "swp-c",
iconRes = R.drawable.ic_exchange_vertical_24,
icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20),
direction = Direction.INCOMING,
status = Status.Confirmed,
title = "Swapped",
@ -525,7 +533,7 @@ private fun Preview_TransactionItem_Swap() {
),
previewContent(
txHash = "swp-u",
iconRes = R.drawable.ic_exchange_vertical_24,
icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20),
direction = Direction.INCOMING,
status = Status.Unconfirmed,
title = "Swapping",
@ -534,7 +542,7 @@ private fun Preview_TransactionItem_Swap() {
),
previewContent(
txHash = "swp-f",
iconRes = R.drawable.ic_close_24,
icon = TxIcon.Vector(Icons.ic_cross_20),
direction = Direction.INCOMING,
status = Status.Failed,
title = "Swapping failed",
@ -561,7 +569,7 @@ private fun Preview_TransactionItem_Express() {
status = Status.Unconfirmed,
direction = Direction.OUTGOING,
onClick = {},
iconRes = R.drawable.ic_exchange_vertical_24,
icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20),
title = stringReference("Swapping"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.TO,
@ -584,7 +592,7 @@ private fun Preview_TransactionItem_Express() {
status = Status.Confirmed,
direction = Direction.INCOMING,
onClick = {},
iconRes = R.drawable.ic_tangem_card_24,
icon = TxIcon.Vector(Icons.ic_card_20),
title = stringReference("Topped up"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.FROM,
@ -601,7 +609,7 @@ private fun Preview_TransactionItem_Express() {
status = Status.Failed,
direction = Direction.INCOMING,
onClick = {},
iconRes = R.drawable.ic_tangem_card_24,
icon = TxIcon.Vector(Icons.ic_card_20),
title = stringReference("Top up failed"),
subtitle = ContentSubtitle.Asset(
direction = ContentSubtitle.Direction.FROM,

View file

@ -1,8 +1,11 @@
package com.tangem.core.ui.components.transactions.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
@ -35,7 +38,7 @@ sealed interface TransactionItemUM {
val status: Status,
val direction: Direction,
val onClick: () -> Unit,
@DrawableRes val iconRes: Int,
val icon: TxIcon,
val title: TextReference,
val subtitle: ContentSubtitle,
val timestamp: Long,
@ -153,4 +156,40 @@ sealed interface TransactionItemUM {
data class Loading(override val txHash: String) : TransactionItemUM
data class Locked(override val txHash: String) : TransactionItemUM
}
/**
* Status-circle icon for a [TransactionItemUM.Content] row.
*
* [Vector] holds a design-system [ImageVector] (`Icons.ic_*`) the migration target; [Res] holds a `@DrawableRes`
* XML fallback for transaction types that don't have a DS3 icon yet (e.g. gear / claim rewards).
*/
@Immutable
sealed interface TxIcon {
/** Design-system vector icon (`Icons.ic_*`). */
data class Vector(val imageVector: ImageVector) : TxIcon
/**
* Legacy XML drawable fallback.
*
* Forced temporary bridge: a few transaction types still map to legacy XML drawables that have no DS3
* `Icons.ic_*` counterpart yet (e.g. gear / claim rewards), while some DS3 icons in turn have no legacy
* drawable. Once the DS team ships the full icon set, every call site switches to [Vector] and this whole
* subclass is removed.
*/
@Deprecated(
message = "Temporary fallback for tx types missing a DS3 icon. Migrate to TxIcon.Vector once the DS team " +
"ships the remaining Icons.ic_* glyphs; this subclass will then be removed.",
replaceWith = ReplaceWith("TxIcon.Vector(imageVector)"),
)
data class Res(@DrawableRes val resId: Int) : TxIcon
}
/** Resolves a [TxIcon] to the [ImageVector] to draw: the DS3 vector directly, or the legacy XML drawable parsed. */
@Suppress("DEPRECATION")
@Composable
fun TxIcon.asImageVector(): ImageVector = when (this) {
is TxIcon.Vector -> imageVector
is TxIcon.Res -> ImageVector.vectorResource(resId)
}

View file

@ -74,6 +74,13 @@ inline val TxHistoryInfo.explorerHash: String?
is ExpressTx -> matchHash
}
/** Human-meaningful transaction id to copy/display: the on-chain hash for an [OnChainTx], the express deal id otherwise. */
inline val TxHistoryInfo.idToCopy: String
get() = when (this) {
is OnChainTx.BSDK -> txInfo.txHash
is ExpressTx -> txId
}
/**
* A history row backed by an express operation. It is a thin wrapper over the standalone express
* model ([ExchangeTransaction] / [OnrampTransaction]), adding only the history-view concerns:

View file

@ -7,11 +7,15 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle.Direction as SubtitleDirection
import com.tangem.core.ui.components.transactions.state.TxIcon
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.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_card_20
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
@ -63,11 +67,11 @@ internal class ExpressTxToTransactionItemUMConverter(
status = status,
amount = formatAmount(viewedAmount, prefix),
direction = if (swap.isOutgoing) RowDirection.OUTGOING else RowDirection.INCOMING,
iconRes = R.drawable.ic_exchange_vertical_24,
icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20),
title = swapTitle(status),
subtitle = ContentSubtitle.Asset(
direction = if (swap.isOutgoing) SubtitleDirection.TO else SubtitleDirection.FROM,
symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId,
symbol = counterparty.displaySymbol,
icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert),
),
warning = swapWarning(swap),
@ -86,7 +90,7 @@ internal class ExpressTxToTransactionItemUMConverter(
status = status,
amount = formatAmount(onramp.tx.toAsset.amount, prefix),
direction = RowDirection.INCOMING,
iconRes = R.drawable.ic_tangem_card_24,
icon = TxIcon.Vector(Icons.ic_card_20),
title = onrampTitle(status),
subtitle = ContentSubtitle.Asset(
direction = SubtitleDirection.FROM,
@ -106,7 +110,7 @@ internal class ExpressTxToTransactionItemUMConverter(
status: Status,
amount: String?,
direction: RowDirection,
iconRes: Int,
icon: TxIcon,
title: TextReference,
subtitle: ContentSubtitle,
warning: TextReference?,
@ -120,7 +124,7 @@ internal class ExpressTxToTransactionItemUMConverter(
status = status,
direction = direction,
onClick = { txHistoryUiActions.onTransactionClick(tx) },
iconRes = iconRes,
icon = icon,
title = title,
subtitle = subtitle,
timestamp = tx.timestampMillis,

View file

@ -0,0 +1,49 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.features.txhistory.impl.R
// region Status helpers
/** Maps the domain [TxInfo.TransactionStatus] to the UI [Status] bucket that drives row title/icon/amount colors. */
internal fun TxInfo.TransactionStatus.toUiStatus(): Status = when (this) {
TxInfo.TransactionStatus.Confirmed -> Status.Confirmed
TxInfo.TransactionStatus.Failed -> Status.Failed
TxInfo.TransactionStatus.Unconfirmed -> Status.Unconfirmed
}
/**
* Status-aware action title: the [confirmed] label once settled, the [pending] label while in flight, and the
* "{pending} failed" template on failure.
*/
internal fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (this) {
is Status.Failed -> resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending)))
is Status.Unconfirmed -> resourceReference(pending)
is Status.Confirmed -> resourceReference(confirmed)
}
/** [statusAwareTitle] keyed off an on-chain [TxInfo]'s status. */
internal fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference =
status.toUiStatus().statusAwareTitle(pending, confirmed)
// endregion
// region Express asset helpers
/** Ticker shown for an express leg: the resolved currency symbol, falling back to the network id while unresolved. */
internal val ExpressTransactionAsset.displaySymbol: String
get() = cryptoCurrency?.symbol ?: id.networkId
/** Fiat currency code of an [Amount], falling back to its symbol when the amount is not a fiat type. */
internal val Amount.fiatCode: String
get() = (type as? AmountType.FiatType)?.code ?: currencySymbol
// endregion

View file

@ -6,13 +6,21 @@ import com.tangem.common.ui.account.getUiColor
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.components.transactions.state.TxIcon
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.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_up_20
import com.tangem.core.ui.res.generated.icons.ic_card_20
import com.tangem.core.ui.res.generated.icons.ic_copy_24
import com.tangem.core.ui.res.generated.icons.ic_globe_24
import com.tangem.core.ui.res.generated.icons.ic_share_android_24
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressExchangeStatus
@ -24,7 +32,6 @@ 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.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
@ -57,6 +64,9 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
private val currency: CryptoCurrency,
private val onCopyAddress: (String) -> Unit,
private val onGoToProvider: (String) -> Unit,
private val onCopyTxId: (() -> Unit)? = null,
private val onShare: (() -> Unit)? = null,
private val onExplore: (() -> Unit)? = null,
private val lookup: TxHistoryLookupContext = TxHistoryLookupContext(
ownAccountByNetwork = emptyMap(),
isAccountsModeEnabled = false,
@ -94,12 +104,49 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
)
private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM(
iconRes = headerIcon(),
icon = headerIcon(),
status = status.toUiStatus(),
title = headerTitle(),
subtitle = headerSubtitle(timestampInMillis),
menu = buildMenu(),
)
/**
* Header overflow context menu, shared by all transaction types. Each row is dropped when its action is absent:
* "Transaction ID" (copy; dropped when [onCopyTxId] is `null` no id to copy), "Share" (dropped when [onShare] is
* `null`) and "Explore" (dropped when [onExplore] is `null`). An empty list leaves the header with no "•••" button.
* Repeat / Hide are not part of this iteration.
*/
private fun buildMenu(): ImmutableList<TxHistoryDetailsUM.MenuItemUM> = buildList {
onCopyTxId?.let { copy ->
add(
TxHistoryDetailsUM.MenuItemUM(
icon = Icons.ic_copy_24,
title = resourceReference(R.string.common_transaction_id),
onClick = copy,
),
)
}
onShare?.let { share ->
add(
TxHistoryDetailsUM.MenuItemUM(
icon = Icons.ic_share_android_24,
title = resourceReference(R.string.common_share),
onClick = share,
),
)
}
onExplore?.let { explore ->
add(
TxHistoryDetailsUM.MenuItemUM(
icon = Icons.ic_globe_24,
title = resourceReference(R.string.common_explore),
onClick = explore,
),
)
}
}.toImmutableList()
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),
@ -163,10 +210,11 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
val toOwner = resolveLegOwner(swap.tx.payoutAddress, swap.tx.toAsset.cryptoCurrency)
return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_exchange_vertical_24,
icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20),
status = status,
title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
subtitle = headerSubtitle(swap.timestampMillis),
menu = buildMenu(),
),
from = swap.tx.fromAsset.toAssetUM(
label = ownerLabel(fromOwner, fallback = R.string.swapping_from_title_v2, owned = R.string.common_from),
@ -191,13 +239,14 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
val toOwner = resolveLegOwner(onramp.tx.payoutAddress, onramp.tx.toAsset.cryptoCurrency)
return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_tangem_card_24,
icon = TxIcon.Vector(Icons.ic_card_20),
status = status,
title = status.statusAwareTitle(
R.string.tx_history_onramp_top_up,
R.string.tx_history_onramp_topped_up,
),
subtitle = headerSubtitle(onramp.timestampMillis),
menu = buildMenu(),
),
from = onramp.tx.fromFiat.toFiatAssetUM(
// The fiat side was paid from a card, not a portfolio address — no owner to resolve.
@ -269,7 +318,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
sign: String,
isFaded: Boolean,
): TxHistoryDetailsUM.AssetUM {
val symbol = cryptoCurrency?.symbol ?: id.networkId
val symbol = displaySymbol
val formatted = amount.format { crypto(
symbol = symbol,
decimals = decimals,
@ -289,7 +338,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
* nor the `~` estimate so only the value is shown. Fiat has no `CryptoCurrency`, so it also has no icon.
*/
private fun Amount.toFiatAssetUM(label: TextReference, isFaded: Boolean): TxHistoryDetailsUM.AssetUM {
val code = (type as? AmountType.FiatType)?.code ?: currencySymbol
val code = fiatCode
val formatted = (value ?: BigDecimal.ZERO)
.format { fiat(fiatCurrencyCode = code, fiatCurrencySymbol = currencySymbol) }
return TxHistoryDetailsUM.AssetUM(
@ -421,12 +470,6 @@ private fun verificationBanner() = TxHistoryDetailsUM.StatusBannerUM(
isLoading = false,
)
private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (this) {
is Status.Failed -> resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending)))
is Status.Unconfirmed -> resourceReference(pending)
is Status.Confirmed -> resourceReference(confirmed)
}
// endregion
// region Info rows (provider / rate / network fee)
@ -489,8 +532,8 @@ private fun ExchangeTransaction.swapRateRow(): TxHistoryDetailsUM.InfoRowUM? {
val fromAmount = fromAsset.amount.takeIfPositive() ?: return null
val toAmount = toAsset.amount.takeIfPositive() ?: return null
val rate = toAmount.divide(fromAmount, rateScale(toAsset.decimals), RoundingMode.HALF_UP)
val baseSymbol = fromAsset.cryptoCurrency?.symbol ?: fromAsset.id.networkId
val quoteSymbol = toAsset.cryptoCurrency?.symbol ?: toAsset.id.networkId
val baseSymbol = fromAsset.displaySymbol
val quoteSymbol = toAsset.displaySymbol
val value = rateText(
base = oneOf(baseSymbol),
quote = rate.format { crypto(symbol = quoteSymbol, decimals = toAsset.decimals, ignoreSymbolPosition = true) },
@ -508,8 +551,8 @@ private fun OnrampTransaction.onrampRateRow(): TxHistoryDetailsUM.InfoRowUM? {
val cryptoReceived = toAsset.amount.takeIfPositive() ?: return null
// Divide at full precision; the fiat formatter then rounds the rate to the currency's display scale.
val rate = fiatPaid.divide(cryptoReceived, RATE_MAX_DECIMALS, RoundingMode.HALF_UP)
val cryptoSymbol = toAsset.cryptoCurrency?.symbol ?: toAsset.id.networkId
val fiatCode = (fromFiat.type as? AmountType.FiatType)?.code ?: fromFiat.currencySymbol
val cryptoSymbol = toAsset.displaySymbol
val fiatCode = fromFiat.fiatCode
val value = rateText(
base = oneOf(cryptoSymbol),
quote = rate.format { fiat(fiatCurrencyCode = fiatCode, fiatCurrencySymbol = fromFiat.currencySymbol) },
@ -576,9 +619,9 @@ private fun TxInfo.signedAmount(currency: CryptoCurrency): String {
// region Header building helpers
/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */
private fun TxInfo.headerIcon(): Int = when (type) {
is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24
else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
private fun TxInfo.headerIcon(): TxIcon = when (type) {
is TransactionType.Swap -> TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20)
else -> TxIcon.Vector(if (isOutgoing) Icons.ic_arrow_up_20 else Icons.ic_arrow_down_20)
}
private fun headerSubtitle(timestampMillis: Long): TextReference {
@ -588,17 +631,4 @@ private fun headerSubtitle(timestampMillis: Long): TextReference {
return stringReference("$date, $time")
}
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)
}
private fun TxInfo.TransactionStatus.toUiStatus(): Status = when (this) {
TxInfo.TransactionStatus.Confirmed -> Status.Confirmed
TxInfo.TransactionStatus.Failed -> Status.Failed
TxInfo.TransactionStatus.Unconfirmed -> Status.Unconfirmed
}
// endregion

View file

@ -6,10 +6,17 @@ 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.components.transactions.state.TxIcon
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.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_refresh_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_up_20
import com.tangem.core.ui.res.generated.icons.ic_cross_20
import com.tangem.core.ui.res.generated.icons.ic_document_20
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.toTimeFormat
@ -85,7 +92,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = stringReference(type.name),
iconRes = tx.directionalIcon(),
icon = tx.directionalIcon(),
subtitle = tx.extractAddressSubtitle(),
)
@ -94,7 +101,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
iconRes = tx.directionalIcon(),
icon = tx.directionalIcon(),
subtitle = tx.extractAddressSubtitle(),
)
@ -129,7 +136,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = title,
iconRes = tx.directionalIcon(),
icon = tx.directionalIcon(),
subtitle = subtitle,
)
}
@ -142,7 +149,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
pending = R.string.transaction_history_claiming_reward,
confirmed = R.string.transaction_history_staking_reward,
),
iconRes = R.drawable.ic_transaction_history_claim_rewards_24,
icon = TxIcon.Res(R.drawable.ic_transaction_history_claim_rewards_24),
subtitle = ContentSubtitle.Plain(resourceReference(R.string.transaction_history_earned_from_stake)),
)
@ -154,7 +161,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_topup),
iconRes = tx.directionalIcon(),
icon = tx.directionalIcon(),
subtitle = tx.yieldSupplySubtitle(currency, type),
)
@ -166,7 +173,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_deploy_contract),
iconRes = R.drawable.ic_doc_24,
icon = TxIcon.Vector(Icons.ic_document_20),
subtitle = tx.yieldSupplySubtitle(currency, type),
)
@ -178,7 +185,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_initialize),
iconRes = R.drawable.ic_gear_24,
icon = TxIcon.Res(R.drawable.ic_gear_24),
subtitle = tx.yieldSupplySubtitle(currency, type),
)
@ -190,7 +197,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_reactivate),
iconRes = R.drawable.ic_refresh_24,
icon = TxIcon.Vector(Icons.ic_arrow_refresh_20),
subtitle = tx.yieldSupplySubtitle(currency, type),
)
@ -206,7 +213,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
} else {
resourceReference(R.string.common_transfer)
},
iconRes = tx.directionalIcon(),
icon = tx.directionalIcon(),
subtitle = tx.yieldSupplySubtitle(currency, type),
hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing,
)
@ -218,7 +225,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.transaction_history_operation),
iconRes = tx.directionalIcon(),
icon = tx.directionalIcon(),
subtitle = tx.extractAddressSubtitle(),
)
@ -227,7 +234,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.gasless_transaction_fee),
iconRes = tx.directionalIcon(),
icon = tx.directionalIcon(),
subtitle = tx.extractAddressSubtitle(),
)
@ -235,7 +242,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
title: TextReference,
iconRes: Int,
icon: TxIcon,
subtitle: ContentSubtitle,
hideAmount: Boolean = false,
): TransactionItemUM.Content = TransactionItemUM.Content(
@ -245,7 +252,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
time = tx.timestampInMillis.toTimeFormat(),
status = uiStatus,
direction = tx.extractDirection(),
iconRes = if (uiStatus is TransactionItemUM.Content.Status.Failed) R.drawable.ic_close_24 else iconRes,
icon = if (uiStatus is TransactionItemUM.Content.Status.Failed) TxIcon.Vector(Icons.ic_cross_20) else icon,
title = title,
subtitle = subtitle,
timestamp = tx.timestampInMillis,
@ -379,20 +386,10 @@ private fun TxInfo.directionalAddressRes(): Int = if (isOutgoing) {
// 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.directionalIcon(): TxIcon =
TxIcon.Vector(if (isOutgoing) Icons.ic_arrow_up_20 else Icons.ic_arrow_down_20)
private fun TxInfo.extractDirection(): TransactionItemUM.Content.Direction = if (isOutgoing) {
TransactionItemUM.Content.Direction.OUTGOING
@ -400,10 +397,4 @@ private fun TxInfo.extractDirection(): TransactionItemUM.Content.Direction = if
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

@ -3,9 +3,11 @@ package com.tangem.features.txhistory.entity
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TxIcon
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@ -208,11 +210,26 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
/**
* Shared bottom-sheet top bar. The icon glyph and [title] text come from the transaction type; [status] drives
* the three visual states (in-progress / confirmed / failed) recoloring the icon circle and the title.
*
* [menu] is the header's overflow context-menu content; empty leaves the trailing menu button inert.
*/
data class HeaderUM(
@DrawableRes val iconRes: Int,
val icon: TxIcon,
val status: TransactionItemUM.Content.Status,
val title: TextReference,
val subtitle: TextReference,
val menu: ImmutableList<MenuItemUM> = persistentListOf(),
)
/**
* One row of the header's overflow context menu: a leading [icon] glyph and a [title] label. [isDestructive]
* renders the row in the error color (e.g. a remove action); [onClick] runs the action and is expected to also
* dismiss the menu at the call site.
*/
data class MenuItemUM(
val icon: ImageVector,
val title: TextReference,
val isDestructive: Boolean = false,
val onClick: () -> Unit,
)
}

View file

@ -4,12 +4,17 @@ import androidx.compose.runtime.Stable
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.share.ShareManager
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.txhistory.model.explorerHash
import com.tangem.domain.txhistory.model.idToCopy
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@ -19,10 +24,13 @@ import javax.inject.Inject
@Stable
@ModelScoped
@Suppress("LongParameterList")
internal class TxHistoryDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager,
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
ownerLookupProducer: TxHistoryOwnerLookupProducer,
paramsContainer: ParamsContainer,
) : Model() {
@ -33,10 +41,17 @@ internal class TxHistoryDetailsModel @Inject constructor(
params.txHistoryInfo,
ownerLookupProducer(),
) { txInfo, lookup ->
// No explorer hash (e.g. an express op with no on-chain leg yet, or a blank on-chain hash) → the "Share" and
// "Explore" rows are dropped; a blank id drops the "Transaction ID" row.
val explorerHash = txInfo.explorerHash?.ifBlank { null }
val idToCopy = txInfo.idToCopy.ifBlank { null }
TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = params.currency,
onCopyAddress = ::onCopyAddress,
onGoToProvider = urlOpener::openUrl,
onCopyTxId = idToCopy?.let { id -> { onCopyTxId(id) } },
onShare = explorerHash?.let { hash -> { share(hash) } },
onExplore = explorerHash?.let { hash -> { explore(hash) } },
lookup = lookup,
).convert(txInfo)
}
@ -47,4 +62,25 @@ internal class TxHistoryDetailsModel @Inject constructor(
private fun onCopyAddress(address: String) {
clipboardManager.setText(text = address, isSensitive = false)
}
/** Copies the transaction id to the clipboard — wired into the header menu's "Transaction ID" row. */
private fun onCopyTxId(id: String) {
clipboardManager.setText(text = id, isSensitive = false)
}
/** Opens the transaction in the blockchain explorer — wired into the header menu's "Explore" row. */
private fun explore(txHash: String) {
getExplorerTransactionUrlUseCase(txHash = txHash, currency = params.currency).fold(
ifLeft = { TangemLogger.e(it.toString()) },
ifRight = { urlOpener.openUrl(url = it) },
)
}
/** Shares the transaction's explorer URL — wired into the header menu's "Share" row. */
private fun share(txHash: String) {
getExplorerTransactionUrlUseCase(txHash = txHash, currency = params.currency).fold(
ifLeft = { TangemLogger.e(it.toString()) },
ifRight = { shareManager.shareText(text = it) },
)
}
}

View file

@ -10,9 +10,15 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.components.transactions.state.TxIcon
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_up_20
import com.tangem.core.ui.res.generated.icons.ic_copy_24
import com.tangem.core.ui.res.generated.icons.ic_globe_24
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import kotlinx.collections.immutable.persistentListOf
@ -65,10 +71,11 @@ private fun TxHistoryDetailsModalBottomSheetContentTwoAssetsPreview() {
/** Fully-populated single-asset state exercising every sub-view: header, amount block, counterparty and info rows. */
private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_arrow_up_24,
icon = TxIcon.Vector(Icons.ic_arrow_up_20),
status = Status.Confirmed,
title = stringReference("Sent"),
subtitle = stringReference("Jan 20 2026, 9:24 PM"),
menu = previewMenu(),
),
amountBlock = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = CurrencyIconState.CoinIcon(
@ -97,10 +104,11 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
/** Failed swap exercising the two-asset body: both legs, the error status banner, provider link row and the CTA. */
private fun previewTwoAssets() = TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_exchange_vertical_24,
icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20),
status = Status.Failed,
title = stringReference("Swap"),
subtitle = stringReference("Jan 20 2026, 9:24 PM"),
menu = previewMenu(),
),
from = TxHistoryDetailsUM.AssetUM(
label = stringReference("You send"),
@ -147,4 +155,18 @@ private fun previewTwoAssets() = TxHistoryDetailsUM.TwoAssets(
),
)
/** Sample header `•••` menu used by the previews. */
private fun previewMenu() = persistentListOf(
TxHistoryDetailsUM.MenuItemUM(
icon = Icons.ic_copy_24,
title = stringReference("Transaction ID"),
onClick = {},
),
TxHistoryDetailsUM.MenuItemUM(
icon = Icons.ic_globe_24,
title = stringReference("Explore"),
onClick = {},
),
)
// endregion

View file

@ -2,26 +2,37 @@ package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
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.Row
import androidx.compose.foundation.layout.WindowInsets
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.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.components.transactions.state.TxIcon
import com.tangem.core.ui.components.transactions.state.asImageVector
import com.tangem.core.ui.ds.contextmenu.TangemContextMenu
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.Close
import com.tangem.core.ui.ds2.button.TangemButton
@ -33,15 +44,20 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_copy_24
import com.tangem.core.ui.res.generated.icons.ic_dots_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_info_24
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Shared top navigation ("Nav bar") for the transaction details bottom sheet, common to all transaction types.
*
* Built on the redesigned [TangemTopNavigation]: a leading status-tinted action icon ([StatusActionIcon]) in the start
* slot, a status-colored [title][TxHistoryDetailsUM.HeaderUM.title] over a date subtitle in the center slot, and the
* trailing context-menu (``, grouped in a Material pill) + close (``) buttons in the end slots.
* trailing overflow context-menu (grouped in a Material pill) + close buttons in the end slots.
*
* Three visual states are driven by [TxHistoryDetailsUM.HeaderUM.status]: the action-icon circle background, the icon
* tint and the title color change between in-progress (brand/blue), confirmed (neutral) and failed (red). The icon
@ -59,16 +75,13 @@ internal fun TxHistoryDetailsTopNavigation(
TangemTopNavigation(
modifier = modifier,
windowInsets = WindowInsets(0),
contentPadding = PaddingValues(16.dp),
blurBackground = false,
startButton = { StatusActionIcon(iconRes = header.iconRes, status = header.status) },
startButton = { StatusActionIcon(icon = header.icon, status = header.status) },
endButtonsGroup = {
// Context menu. Click handling is intentionally not wired yet.
TangemButton(
variant = TangemButton.Variant.Ghost,
iconStart = TangemIconUM.Icon(Icons.ic_dots_horizontal_20),
contentDescription = resourceReference(R.string.common_more).resolveReference(),
onClick = {},
)
if (header.menu.isNotEmpty()) {
TxHistoryDetailsOverflowMenu(menu = header.menu)
}
},
endButton = { TangemButton.Close(onClick = onCloseClick) },
contentColumn = {
@ -88,8 +101,93 @@ internal fun TxHistoryDetailsTopNavigation(
)
}
/**
* The header's "•••" overflow button and its drop-down [TangemContextMenu]. A destructive row (e.g. "Hide transaction")
* is preceded by a divider when it is not the first item. Tapping a row closes the menu and fires the item's action.
*/
@Composable
private fun StatusActionIcon(iconRes: Int, status: Status, modifier: Modifier = Modifier) {
private fun TxHistoryDetailsOverflowMenu(
menu: ImmutableList<TxHistoryDetailsUM.MenuItemUM>,
modifier: Modifier = Modifier,
) {
var isMenuExpanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
TangemButton(
variant = TangemButton.Variant.Ghost,
iconStart = TangemIconUM.Icon(Icons.ic_dots_horizontal_20),
contentDescription = resourceReference(R.string.common_more).resolveReference(),
onClick = { isMenuExpanded = true },
)
TangemContextMenu(
expanded = isMenuExpanded,
onDismissRequest = { isMenuExpanded = false },
) {
menu.forEachIndexed { index, item ->
if (item.isDestructive && index > 0) {
HorizontalDivider(
thickness = 0.5.dp,
color = TangemTheme.colors2.border.neutral.quaternary,
)
}
TxHistoryDetailsMenuItem(
item = item,
onClick = {
isMenuExpanded = false
item.onClick()
},
)
}
}
}
}
/**
* One row of the header's overflow context menu: a leading 24dp glyph and a label, padded to the iOS-style menu metrics
* (20dp horizontal, 10dp vertical, 8dp gap). A [destructive][TxHistoryDetailsUM.MenuItemUM.isDestructive] row is tinted
* with the error color (icon + text).
*/
@Composable
private fun TxHistoryDetailsMenuItem(
item: TxHistoryDetailsUM.MenuItemUM,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val textColor = if (item.isDestructive) {
TangemTheme.colors3.text.status.error
} else {
TangemTheme.colors3.text.primary
}
val iconTint = if (item.isDestructive) {
TangemTheme.colors3.icon.status.error
} else {
TangemTheme.colors3.icon.primary
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = item.icon,
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Text(
text = item.title.resolveReference(),
color = textColor,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun StatusActionIcon(icon: TxIcon, status: Status, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(44.dp)
@ -98,7 +196,7 @@ private fun StatusActionIcon(iconRes: Int, status: Status, modifier: Modifier =
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(iconRes),
imageVector = icon.asImageVector(),
contentDescription = null,
tint = status.iconTint,
modifier = Modifier.size(20.dp),
@ -161,10 +259,24 @@ private fun TxHistoryDetailsTopNavigationPreview() {
}
private fun previewHeader(status: Status, title: TextReference) = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_exchange_vertical_24,
icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20),
status = status,
title = title,
subtitle = stringReference("Jan 20 2026, 9:24 PM"),
menu = previewMenu(),
)
private fun previewMenu() = persistentListOf(
TxHistoryDetailsUM.MenuItemUM(
icon = Icons.ic_copy_24,
title = stringReference("Transaction ID"),
onClick = {},
),
TxHistoryDetailsUM.MenuItemUM(
icon = Icons.ic_info_24,
title = stringReference("Explore"),
onClick = {},
),
)
// endregion

View file

@ -5,10 +5,18 @@ import androidx.compose.ui.graphics.Color
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TxIcon
import com.tangem.core.ui.ds.image.DeviceIconUM
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.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_card_20
import com.tangem.core.ui.res.generated.icons.ic_copy_24
import com.tangem.core.ui.res.generated.icons.ic_globe_24
import com.tangem.core.ui.res.generated.icons.ic_share_android_24
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
@ -128,7 +136,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
val header = converter.convert(tx).header
// Assert
assertThat(header.iconRes).isEqualTo(R.drawable.ic_arrow_down_24)
assertThat(header.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_down_20))
assertThat(header.status).isEqualTo(TransactionItemUM.Content.Status.Confirmed)
assertThat(header.title).isEqualTo(resourceReference(R.string.common_received))
}
@ -202,7 +210,81 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
val header = converter.convert(tx).header
// Assert
assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24)
assertThat(header.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20))
}
@Test
fun `GIVEN menu callbacks WHEN convert THEN header menu has copy-id, share and explore rows wired`() {
// Arrange
var copiedTxId = false
var shared = false
var explored = false
val menuConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
onGoToProvider = openedUrls::add,
onCopyTxId = { copiedTxId = true },
onShare = { shared = true },
onExplore = { explored = true },
)
// Act
val menu = menuConverter.convert(onChain(type = TransactionType.Transfer)).header.menu
// Assert
assertThat(menu).hasSize(3)
assertThat(menu[0].icon).isEqualTo(Icons.ic_copy_24)
assertThat(menu[0].title).isEqualTo(resourceReference(R.string.common_transaction_id))
assertThat(menu[1].icon).isEqualTo(Icons.ic_share_android_24)
assertThat(menu[1].title).isEqualTo(resourceReference(R.string.common_share))
assertThat(menu[2].icon).isEqualTo(Icons.ic_globe_24)
assertThat(menu[2].title).isEqualTo(resourceReference(R.string.common_explore))
menu[0].onClick()
menu[1].onClick()
menu[2].onClick()
assertThat(copiedTxId).isTrue()
assertThat(shared).isTrue()
assertThat(explored).isTrue()
}
@Test
fun `GIVEN no share and explore callbacks WHEN convert THEN header menu drops the share and explore rows`() {
// Arrange — onShare/onExplore are null (e.g. an express op with no on-chain leg to share or open yet).
val menuConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
onGoToProvider = openedUrls::add,
onCopyTxId = {},
onShare = null,
onExplore = null,
)
// Act
val menu = menuConverter.convert(onChain(type = TransactionType.Transfer)).header.menu
// Assert
assertThat(menu).hasSize(1)
assertThat(menu[0].title).isEqualTo(resourceReference(R.string.common_transaction_id))
}
@Test
fun `GIVEN no menu callbacks WHEN convert THEN header menu is empty`() {
// Arrange — every menu action is absent (e.g. a blank tx id with no on-chain leg to share or open).
val menuConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
onGoToProvider = openedUrls::add,
onCopyTxId = null,
onShare = null,
onExplore = null,
)
// Act
val menu = menuConverter.convert(onChain(type = TransactionType.Transfer)).header.menu
// Assert
assertThat(menu).isEmpty()
}
@Test
@ -364,7 +446,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
// Assert
assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java)
assertThat(result.header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24)
assertThat(result.header.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20))
}
@Test
@ -649,7 +731,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.header.iconRes).isEqualTo(R.drawable.ic_tangem_card_24)
assertThat(result.header.icon).isEqualTo(TxIcon.Vector(Icons.ic_card_20))
assertThat(result.statusBanner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,

View file

@ -3,8 +3,15 @@ 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.components.transactions.state.TxIcon
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_refresh_20
import com.tangem.core.ui.res.generated.icons.ic_arrow_up_20
import com.tangem.core.ui.res.generated.icons.ic_cross_20
import com.tangem.core.ui.res.generated.icons.ic_document_20
import com.tangem.domain.models.account.Account.CryptoPortfolio.Companion.createMainAccount
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
@ -78,7 +85,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
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)
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_down_20))
}
@Test
@ -119,7 +126,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
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)
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_cross_20))
}
@Test
@ -193,7 +200,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
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)
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_up_20))
val subtitle = result.subtitle as ContentSubtitle.ExternalAddress
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO)
assertThat(subtitle.rawAddress).isEqualTo(USER_ADDRESS)
@ -242,7 +249,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
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)
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_down_20))
val subtitle = result.subtitle as ContentSubtitle.ExternalAddress
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM)
}
@ -436,7 +443,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
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)
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_document_20))
}
@Test
@ -446,7 +453,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
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)
assertThat(result.icon).isEqualTo(TxIcon.Res(R.drawable.ic_gear_24))
}
@Test
@ -456,7 +463,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
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)
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_arrow_refresh_20))
}
@Test
@ -655,7 +662,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.iconRes).isEqualTo(R.drawable.ic_close_24)
assertThat(result.icon).isEqualTo(TxIcon.Vector(Icons.ic_cross_20))
}
@Test