diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt index 0cacb2de38..94a4fd6c60 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt @@ -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, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt index 7bce8ba673..b82201c013 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt @@ -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) } \ No newline at end of file diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt index 8324ce94b6..0c159ba5be 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt @@ -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: diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt index 628d829e53..bc05e6facd 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt @@ -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, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxConverterHelpers.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxConverterHelpers.kt new file mode 100644 index 0000000000..dee143b2e8 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxConverterHelpers.kt @@ -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 \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt index c8c802c4f4..b9e0b9fe2c 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt @@ -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 = 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 \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt index 35630275e2..1f894d8563 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt @@ -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 \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index 6d80635f1a..f158ee4ea3 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -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 = 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, ) } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt index 3486e8041f..16b72c8b15 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -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) }, + ) + } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt index ce6000cd73..9261a5ec0f 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt @@ -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 \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt index 35772b1847..06288b1e39 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt @@ -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, + 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 \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index 5e56147711..00b1cfd951 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -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, diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt index 234928cdf1..2ba322a55d 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt @@ -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