Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 11:33:10 +03:00
parent 1beceb4744
commit 9bc5ee1684
11 changed files with 896 additions and 659 deletions

View file

@ -45,6 +45,8 @@ dependencies {
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.account.status)
implementation(projects.domain.onramp.models)
implementation(projects.domain.staking)
implementation(projects.domain.staking.models)
/* AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -0,0 +1,445 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.common.ui.account.getResId
import com.tangem.common.ui.account.getUiColor
import com.tangem.common.ui.account.toUM
import com.tangem.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.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_swap_horizontal_20
import com.tangem.core.ui.res.generated.icons.ic_card_20
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.model.ResolvedOwner
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.model.resolveOwner
import com.tangem.utils.StringsSigns
import com.tangem.utils.toBriefAddressFormat
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Converts an [ExpressTx] (swap / onramp) to the [TxHistoryDetailsUM.TwoAssets] details card. The `from`/`to` legs come
* from the express deal ([ExchangeTransaction] asset pair / [OnrampTransaction] fiatasset), and the network-fee row
* comes from the matched on-chain leg ([ExpressTx.txInfo]).
*/
internal class ExpressTxToDetailsUMConverter(
private val onGoToProvider: (String) -> Unit,
private val lookup: TxHistoryLookupContext,
private val menu: ImmutableList<TxHistoryDetailsUM.MenuItemUM>,
) {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter()
private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter()
fun convert(value: ExpressTx): TxHistoryDetailsUM.TwoAssets = when (value) {
is ExpressTx.Swap -> convertExpressSwap(value)
is ExpressTx.Onramp -> convertExpressOnramp(value)
}
/**
* The two-asset block always renders the deal's `fromAsset``toAsset` regardless of [ExpressTx.Swap.isOutgoing]
* `isOutgoing` only selects which leg is the *viewed* one in the history row, it does not reorder the detail legs.
*/
private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets {
val status = exchangeStatusConverter.convert(swap.tx.status)
val fromOwner = resolveLegOwner(swap.tx.fromAddress, swap.tx.fromAsset.cryptoCurrency)
val toOwner = resolveLegOwner(swap.tx.payoutAddress, swap.tx.toAsset.cryptoCurrency)
return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
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 = menu,
),
from = swap.tx.fromAsset.toAssetUM(
label = ownerLabel(fromOwner, fallback = R.string.swapping_from_title_v2, owned = R.string.common_from),
owner = fromOwner,
sign = status.outgoingSign(),
isFaded = status is Status.Failed,
),
to = swap.tx.toAsset.toAssetUM(
label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to),
owner = toOwner,
sign = status.incomingSign(),
isFaded = status is Status.Failed,
),
statusBanner = swap.tx.status.toStatusBannerUM(),
rows = swap.toInfoRows(onProviderClick = swap.providerClick(), rateRow = swap.tx.swapRateRow()),
providerButton = providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()),
)
}
private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets {
val status = onrampStatusConverter.convert(onramp.tx.status)
val toOwner = resolveLegOwner(onramp.tx.payoutAddress, onramp.tx.toAsset.cryptoCurrency)
return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
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 = menu,
),
from = onramp.tx.fromFiat.toFiatAssetUM(
// The fiat side was paid from a card, not a portfolio address — no owner to resolve.
label = resourceReference(R.string.tx_history_you_paid),
isFaded = status is Status.Failed,
),
to = onramp.tx.toAsset.toAssetUM(
label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to),
owner = toOwner,
sign = status.incomingSign(),
isFaded = status is Status.Failed,
),
statusBanner = onramp.tx.status.toStatusBannerUM(),
rows = onramp.toInfoRows(onProviderClick = onramp.providerClick(), rateRow = onramp.tx.onrampRateRow()),
providerButton = providerButton(onramp.externalTxUrl, onramp.tx.status.providerButtonLabel()),
)
}
/**
* Resolves a swap/onramp leg's [address] (on the leg currency's network) to the owner shown under the amount:
* the user's own account / wallet, or the external [TxHistoryDetailsUM.AssetOwnerUM.Address] (e.g. a send-and-swap
* payout). `null` when there is no address to resolve (e.g. the very-old-version missing `fromAddress`, onramp fiat).
*/
private fun resolveLegOwner(address: String?, legCurrency: CryptoCurrency?): TxHistoryDetailsUM.AssetOwnerUM? {
if (address == null) return null
return when (val resolved = lookup.resolveOwner(address, legCurrency?.network?.id?.rawId)) {
is ResolvedOwner.OwnAccount -> TxHistoryDetailsUM.AssetOwnerUM.Account(
name = resolved.account.accountName.toUM().value,
iconResId = resolved.account.icon.value.getResId(),
backgroundColor = resolved.account.icon.color.getUiColor(),
)
is ResolvedOwner.OwnWallet -> TxHistoryDetailsUM.AssetOwnerUM.Wallet(
name = stringReference(resolved.walletInfo.name),
deviceIconUM = resolved.walletInfo.deviceIconUM,
)
is ResolvedOwner.External -> TxHistoryDetailsUM.AssetOwnerUM.Address(
name = stringReference(resolved.address.toBriefAddressFormat()),
rawAddress = resolved.address,
)
}
}
/** Leg caption: the direction-only [fallback] ("You send" / "You receive") without an owner, "From" / "To" with one. */
private fun ownerLabel(
owner: TxHistoryDetailsUM.AssetOwnerUM?,
@StringRes fallback: Int,
@StringRes owned: Int,
): TextReference = resourceReference(if (owner != null) owned else fallback)
/** Opens the deal's provider page on tap; `null` when the deal has no provider link. */
private fun ExpressTx.providerClick(): (() -> Unit)? = externalTxUrl?.let { url -> { onGoToProvider(url) } }
private fun providerButton(url: String?, @StringRes label: Int?): TxHistoryDetailsUM.ProviderButtonUM? {
if (url == null || label == null) return null
return TxHistoryDetailsUM.ProviderButtonUM(
text = resourceReference(label),
onClick = { onGoToProvider(url) },
)
}
/**
* Builds one crypto leg of the two-asset block. The ticker symbol and icon come from the resolved
* [ExpressTransactionAsset.cryptoCurrency]; when it is unresolved the symbol falls back to the network id and the
* icon slot is left empty ([TxHistoryDetailsUM.AssetUM.currencyIcon] = `null`).
*/
private fun ExpressTransactionAsset.toAssetUM(
label: TextReference,
owner: TxHistoryDetailsUM.AssetOwnerUM?,
sign: String,
isFaded: Boolean,
): TxHistoryDetailsUM.AssetUM {
val symbol = displaySymbol
val formatted = amount.format { crypto(
symbol = symbol,
decimals = decimals,
ignoreSymbolPosition = true,
) }.trim()
return TxHistoryDetailsUM.AssetUM(
label = label,
owner = owner,
amount = stringReference((sign + formatted).trim()),
currencyIcon = cryptoCurrency?.let(iconStateConverter::convert),
isFaded = isFaded,
)
}
/**
* Builds the fiat ("You paid") leg of an onramp. The paid fiat amount is exact and carries no sign neither `+`/``
* 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 = fiatCode
val formatted = (value ?: BigDecimal.ZERO)
.format { fiat(fiatCurrencyCode = code, fiatCurrencySymbol = currencySymbol) }
return TxHistoryDetailsUM.AssetUM(
label = label,
owner = null,
amount = stringReference(formatted.trim()),
currencyIcon = null,
isFaded = isFaded,
)
}
}
// region Status banners
/**
* Express swap status the status plaque under the two-asset block.
*
* In-flight stages render as [Severity.Info] with the rotating loader; [Verifying][ExpressExchangeStatus.Verifying]
* (KYC) and the paused / refunded terminals as [Severity.Warning]; the failure terminals as [Severity.Error]; the
* [Finished][ExpressExchangeStatus.Finished] success as [Severity.Success] (the plaque then auto-collapses see
* `TxHistoryDetailsStatusBanner`). [Unknown][ExpressExchangeStatus.Unknown] carries nothing to show, so it hides the
* plaque (`null`).
*/
private fun ExpressExchangeStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) {
ExpressExchangeStatus.Preview,
ExpressExchangeStatus.Created,
ExpressExchangeStatus.ExchangeTxSent,
ExpressExchangeStatus.Waiting,
-> loadingBanner(R.string.express_exchange_status_receiving_active)
ExpressExchangeStatus.WaitingTxHash -> loadingBanner(R.string.express_exchange_status_waiting_tx_hash)
ExpressExchangeStatus.Confirming -> loadingBanner(R.string.express_exchange_status_confirming_active)
ExpressExchangeStatus.Exchanging -> loadingBanner(R.string.express_exchange_status_exchanging_active)
ExpressExchangeStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active)
ExpressExchangeStatus.Verifying -> verificationBanner()
ExpressExchangeStatus.Refunded -> warningBanner(R.string.express_exchange_status_refunded)
ExpressExchangeStatus.Paused -> warningBanner(R.string.express_exchange_status_paused)
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
-> failedBanner()
ExpressExchangeStatus.Expired -> errorBanner(R.string.express_exchange_status_failed)
ExpressExchangeStatus.Finished -> successBanner(R.string.express_exchange_status_exchanged)
ExpressExchangeStatus.Unknown -> null
}
/**
* Express onramp status the status plaque under the two-asset block. Same severity mapping as the swap variant; the
* [Finished][ExpressOnrampStatus.Finished] success ("Purchase completed") is the only [Severity.Success] (auto-collapsed).
*/
private fun ExpressOnrampStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) {
ExpressOnrampStatus.Created,
ExpressOnrampStatus.WaitingForPayment,
-> loadingBanner(R.string.express_exchange_status_receiving_active)
ExpressOnrampStatus.PaymentProcessing -> loadingBanner(R.string.express_exchange_status_confirming_active)
ExpressOnrampStatus.Verifying -> verificationBanner()
ExpressOnrampStatus.Paid -> loadingBanner(R.string.express_exchange_status_buying_active)
ExpressOnrampStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active)
ExpressOnrampStatus.Paused -> warningBanner(R.string.express_exchange_status_paused)
ExpressOnrampStatus.Failed -> failedBanner()
ExpressOnrampStatus.Expired -> errorBanner(R.string.express_exchange_status_failed)
ExpressOnrampStatus.Finished -> successBanner(R.string.express_exchange_status_bought)
ExpressOnrampStatus.Unknown -> null
}
/**
* Label of the bottom CTA for an express swap, or `null` for statuses that need no provider action. The KYC
* [Verifying][ExpressExchangeStatus.Verifying] state sends the user to verification; the failure terminals send them
* to the provider (to track / refund). Mirrors the failed/verification banners (the existing express block uses the
* same per-tx link for both).
*/
@StringRes
private fun ExpressExchangeStatus.providerButtonLabel(): Int? = when (this) {
ExpressExchangeStatus.Verifying -> R.string.common_go_to_verification
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
ExpressExchangeStatus.Expired,
-> R.string.common_go_to_provider
else -> null
}
/** Label of the bottom CTA for an express onramp, or `null` for statuses that need no provider action. */
@StringRes
private fun ExpressOnrampStatus.providerButtonLabel(): Int? = when (this) {
ExpressOnrampStatus.Verifying -> R.string.common_go_to_verification
ExpressOnrampStatus.Failed,
ExpressOnrampStatus.Expired,
-> R.string.common_go_to_provider
else -> null
}
private fun loadingBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(title),
isLoading = true,
)
private fun successBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Success,
title = resourceReference(title),
isLoading = false,
)
private fun warningBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Warning,
title = resourceReference(title),
isLoading = false,
)
private fun errorBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Error,
title = resourceReference(title),
isLoading = false,
)
/** Failure terminal: red plaque with the shared "visit provider to refund" hint. */
private fun failedBanner() = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
)
/** KYC verification: amber plaque with the "visit provider for verification" hint. */
private fun verificationBanner() = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Warning,
title = resourceReference(R.string.express_exchange_status_verifying),
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
isLoading = false,
)
// endregion
// region Info rows (provider / rate / network fee)
/**
* Detail rows of an express op, in order: the [provider] row (its name), the effective-[rateRow] row, then the
* network-fee row from the matched on-chain leg. Each is dropped when its data is absent the provider while it is
* unresolved, the rate while an amount is missing / non-positive (see [swapRateRow] / [onrampRateRow]), the fee while
* no on-chain leg / fee is present.
*/
private fun ExpressTx.toInfoRows(
onProviderClick: (() -> Unit)?,
rateRow: TxHistoryDetailsUM.InfoRowUM?,
): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = buildList {
provider?.let { add(it.providerRow(onProviderClick)) }
rateRow?.let { add(it) }
addAll(txInfo.toInfoRows())
}.toImmutableList()
private fun ExpressProvider.providerRow(onClick: (() -> Unit)?): TxHistoryDetailsUM.InfoRowUM =
TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.express_provider),
value = stringReference(name),
// The arrow link affordance is shown only when the row opens the provider page.
trailingIconRes = onClick?.let { R.drawable.ic_arrow_top_right_24 },
onClick = onClick,
)
/** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */
private fun OnChainTx?.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> =
(this as? OnChainTx.BSDK)?.txInfo?.toInfoRows() ?: persistentListOf()
// endregion
// region Rate row
private const val RATE_MAX_DECIMALS = 8
private const val RATE_IF_ZERO_DECIMALS = 2
/**
* Effective swap rate row `1 {from} {x} {to}`, computed on the fly as `x = toAmount / fromAmount` (`toAmount` is
* already the actual-or-expected payout the data layer coalesces `actualAmount ?: amount`). Hidden (`null`) when an
* amount is missing or non-positive there is then no rate to show and division by zero is avoided.
*/
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.displaySymbol
val quoteSymbol = toAsset.displaySymbol
val value = rateText(
base = oneOf(baseSymbol),
quote = rate.format { crypto(symbol = quoteSymbol, decimals = toAsset.decimals, ignoreSymbolPosition = true) },
)
return rateRowUM(value)
}
/**
* Effective onramp rate row `1 {crypto} {x} {fiat}`, computed on the fly as `x = fiatPaid / cryptoReceived`. The API's
* nominal `rate` / `rate_usd` are intentionally ignored to avoid UI drift from hidden fees. Hidden (`null`) when an
* amount is missing or non-positive.
*/
private fun OnrampTransaction.onrampRateRow(): TxHistoryDetailsUM.InfoRowUM? {
val fiatPaid = fromFiat.value.takeIfPositive() ?: return null
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.displaySymbol
val fiatCode = fromFiat.fiatCode
val value = rateText(
base = oneOf(cryptoSymbol),
quote = rate.format { fiat(fiatCurrencyCode = fiatCode, fiatCurrencySymbol = fromFiat.currencySymbol) },
)
return rateRowUM(value)
}
private fun rateRowUM(value: String): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.common_rate),
value = stringReference(value),
)
/** Division scale: the quote's decimals, capped at [RATE_MAX_DECIMALS]; a zero-decimal quote still shows two. */
private fun rateScale(quoteDecimals: Int): Int =
(if (quoteDecimals == 0) RATE_IF_ZERO_DECIMALS else quoteDecimals).coerceAtMost(RATE_MAX_DECIMALS)
/**
* Leading `1 {symbol}` of the rate, e.g. `1 POL` number-first, matching the amount legs (the crypto formatter forces a
* two-decimal minimum, so the literal `1` is built directly rather than via [crypto]).
*/
private fun oneOf(symbol: String): String = "1${StringsSigns.NON_BREAKING_SPACE}$symbol"
private fun rateText(base: String, quote: String): String {
return "${base.trim()} ${StringsSigns.APPROXIMATE} ${quote.trim()}"
}
private fun BigDecimal?.takeIfPositive(): BigDecimal? = this?.takeIf { it > BigDecimal.ZERO }
// endregion
// region Amount signs
/** Leading sign of the pay-in / "You send" leg: `` while in flight or settled, dropped on a failed deal. */
private fun Status.outgoingSign(): String = if (this is Status.Failed) "" else "${StringsSigns.MINUS} "
/**
* Leading sign of the payout / "You receive" leg: `~` while in flight (the final received amount is still an estimate),
* `+` once the funds have settled, and dropped on a failed deal (the amount is then only struck through).
*/
private fun Status.incomingSign(): String = when (this) {
is Status.Unconfirmed -> "${StringsSigns.TILDE_SIGN} "
is Status.Confirmed -> "${StringsSigns.PLUS} "
is Status.Failed -> ""
}
// endregion

View file

@ -0,0 +1,188 @@
package com.tangem.features.txhistory.converter
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
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.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_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_chart_line_vertical_20
import com.tangem.core.ui.res.generated.icons.ic_document_20
import com.tangem.core.ui.res.generated.icons.ic_stack_20
import com.tangem.core.ui.res.generated.icons.ic_success_20
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.staking.model.stakekit.Yield
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.impl.R
import com.tangem.utils.StringsSigns
import com.tangem.utils.extensions.isZero
import com.tangem.utils.toBriefAddressFormat
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Converts an on-chain [TxInfo] to the [TxHistoryDetailsUM.SingleAsset] details card (Receive / Send / Transfer /
* staking / yield-supply). A two-asset swap always surfaces as an [com.tangem.domain.txhistory.model.ExpressTx.Swap]
* (handled by [ExpressTxToDetailsUMConverter]); an on-chain `TxInfo` of type `Swap` (e.g. a DEX swap with no express
* record) carries no legs, so it falls back to the single amount it does have rather than an empty two-asset card.
*/
internal class OnChainTxToDetailsUMConverter(
private val currency: CryptoCurrency,
private val onCopyAddress: (String) -> Unit,
private val menu: ImmutableList<TxHistoryDetailsUM.MenuItemUM>,
/** Staking validators of the viewed currency keyed by on-chain address; resolves the validator row's name/link. */
private val validatorsByAddress: Map<String, Yield.Validator>,
private val onOpenValidator: (String) -> Unit,
/** Own deposit addresses on the viewed currency's network — drives the on-chain own-vs-external transfer title. */
private val ownAddresses: Set<String>,
) {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
private val titleConverter = TxHistoryTitleConverter()
fun convert(value: TxInfo): TxHistoryDetailsUM.SingleAsset = TxHistoryDetailsUM.SingleAsset(
header = value.toHeaderUM(),
amountBlock = value.toAmountBlockUM(),
counterparty = value.toCounterpartyUM(),
// Validator (staking) / protocol (yield-supply), then the network fee from the tx itself; rate is not surfaced.
rows = buildList {
value.validatorRow()?.let(::add)
value.protocolRow()?.let(::add)
addAll(value.toInfoRows())
}.toImmutableList(),
)
/**
* Protocol row of a yield-supply tx: the DeFi protocol the funds are supplied to. The yield-supply product is a
* single hard-wired integration across the app (Aave the [yield_module_provider][R.string.yield_module_provider]
* name), so the value is that constant provider rather than a per-tx resolved name. Mutually exclusive with
* [validatorRow] a tx is either staking or yield-supply, never both.
*/
private fun TxInfo.protocolRow(): TxHistoryDetailsUM.InfoRowUM? {
if (type !is TransactionType.YieldSupply) return null
return TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.staking_validator),
value = resourceReference(R.string.yield_module_provider),
)
}
/**
* Validator row of a staking tx: its display [name][Yield.Validator.name] and a link to the validator page. Present
* only when the tx carries a validator address ([validatorAddress]) that resolves in [validatorsByAddress]; otherwise
* `null` (non-staking tx, an address the current yield doesn't list, or a chain that omits the validator address).
* The trailing arrow-link and tap are offered only when the validator has a [website][Yield.Validator.website].
*/
private fun TxInfo.validatorRow(): TxHistoryDetailsUM.InfoRowUM? {
val validator = validatorAddress()?.let(validatorsByAddress::get) ?: return null
val website = validator.website?.ifBlank { null }
return TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.staking_validator),
value = stringReference(validator.name),
trailingIconRes = website?.let { R.drawable.ic_arrow_top_right_24 },
onClick = website?.let { url -> { onOpenValidator(url) } },
)
}
/**
* On-chain address of the validator a staking tx interacts with, or `null` for a non-staking tx or a staking tx that
* does not surface it. A `Vote` carries it in the type itself; other staking types expose it through the interaction
* or destination address typed as `Validator`.
*/
private fun TxInfo.validatorAddress(): String? = when (val txType = type) {
is TransactionType.Staking.Vote -> txType.validatorAddress
is TransactionType.Staking -> interactionValidatorAddress() ?: destinationValidatorAddress()
else -> null
}
private fun TxInfo.interactionValidatorAddress(): String? =
(interactionAddressType as? TxInfo.InteractionAddressType.Validator)?.address
private fun TxInfo.destinationValidatorAddress(): String? = when (val destination = destinationType) {
is TxInfo.DestinationType.Single -> (destination.addressType as? TxInfo.AddressType.Validator)?.address
is TxInfo.DestinationType.Multiple ->
destination.addressTypes.filterIsInstance<TxInfo.AddressType.Validator>().firstOrNull()?.address
}
private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM(
icon = headerIcon(),
status = status.toUiStatus(),
title = titleConverter.convert(this, isOwnTransfer = isOwnTransfer()),
subtitle = headerSubtitle(timestampInMillis),
menu = menu,
)
/** A transfer whose counterparty is one of the viewed currency's own deposit addresses reads "Transfer". */
private fun TxInfo.isOwnTransfer(): Boolean {
val counterpartyAddress = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address
return counterpartyAddress != null && counterpartyAddress in ownAddresses
}
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),
isFailed = status is TxInfo.TransactionStatus.Failed,
)
/**
* Counterparty card ("Recipient" / "From"). Only the external-address avatar is produced built from the `User`
* interaction address (the same source the history list uses for its external-address subtitle); a counterparty that
* is not a plain external `User` address yields no card (`null`).
*
* The lookup needed to resolve an own-account / own-wallet avatar here is already available (it drives the
* swap/onramp leg owners), but applying it to the single-asset counterparty card is intentionally out of scope for
* now a follow-up.
*/
private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? {
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
return TxHistoryDetailsUM.CounterpartyUM(
label = counterpartyLabel(),
title = stringReference(address.toBriefAddressFormat()),
avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address),
onCopyClick = { onCopyAddress(address) },
)
}
/** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */
private fun TxInfo.counterpartyLabel(): TextReference =
if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from)
}
// region Amount / header building helpers
/**
* Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+`
* otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) the UI then only
* strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed].
*/
private fun TxInfo.signedAmount(currency: CryptoCurrency): String {
val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) }
val prefix = when {
status is TxInfo.TransactionStatus.Failed -> ""
amount.isZero() -> ""
isOutgoing -> "${StringsSigns.MINUS} "
else -> "${StringsSigns.PLUS} "
}
return (prefix + formatted).trim()
}
/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */
private fun TxInfo.headerIcon(): TxIcon = when (type) {
is TransactionType.Approve -> TxIcon.Vector(Icons.ic_success_20)
is TransactionType.Staking.Stake,
is TransactionType.Staking.Unstake,
is TransactionType.Staking.Restake,
-> TxIcon.Vector(Icons.ic_stack_20)
is TransactionType.YieldSupply -> TxIcon.Vector(Icons.ic_chart_line_vertical_20)
is TransactionType.Operation -> TxIcon.Vector(Icons.ic_document_20)
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)
}
// endregion

View file

@ -4,12 +4,24 @@ 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.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.res.generated.icons.Icons
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.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.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.joda.time.DateTime
// region Status helpers
@ -46,4 +58,77 @@ internal val ExpressTransactionAsset.displaySymbol: String
internal val Amount.fiatCode: String
get() = (type as? AmountType.FiatType)?.code ?: currencySymbol
// endregion
// region Details header helpers
/**
* Header overflow context menu of the details card, 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.
*/
internal fun buildDetailsMenu(
onCopyTxId: (() -> Unit)?,
onShare: (() -> Unit)?,
onExplore: (() -> Unit)?,
): 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()
internal fun headerSubtitle(timestampMillis: Long): TextReference {
val dateTime = DateTime(timestampMillis)
val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime)
val time = DateTimeFormatters.timeFormatter.print(dateTime)
return stringReference("$date, $time")
}
// endregion
// region Network-fee row
/**
* Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). Shared
* by the on-chain details card and the express card (which pulls the fee from its matched on-chain leg).
*/
internal fun TxInfo.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> =
listOfNotNull(feeRow()).toImmutableList()
private fun TxInfo.feeRow(): TxHistoryDetailsUM.InfoRowUM? {
val fee = fee ?: return null
val value = fee.value ?: return null
return TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.common_network_fee_title),
value = stringReference(
value.format { crypto(symbol = fee.currencySymbol, decimals = fee.decimals, ignoreSymbolPosition = true) },
),
)
}
// endregion

View file

@ -28,8 +28,8 @@ internal class TxHistoryInfoToTransactionItemUMConverter(
private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) {
is OnChainTx.BSDK -> when (val um = txInfoConverter.convert(value.txInfo)) {
// Content rows (transfer/swap/…) route through the details/explorer decision; pills stay on the explorer.
is TransactionItemUM.Content -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) })
is TransactionItemUM.Pill -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) })
else -> um
}
}

View file

@ -1,634 +1,56 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.common.ui.account.getResId
import com.tangem.common.ui.account.getUiColor
import com.tangem.common.ui.account.toUM
import com.tangem.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.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
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
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.staking.model.stakekit.Yield
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.model.ResolvedOwner
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.model.resolveOwner
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.toBriefAddressFormat
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.joda.time.DateTime
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card.
*
* The dispatch mirrors the row converters: an [OnChainTx.BSDK] always renders as [TxHistoryDetailsUM.SingleAsset]
* (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) renders as
* [TxHistoryDetailsUM.TwoAssets] the `from`/`to` legs come from the express deal ([ExchangeTransaction] asset pair /
* [OnrampTransaction] fiatasset), and the network-fee row comes from the matched on-chain leg ([ExpressTx.txInfo]).
* Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card, dispatching to the
* per-shape converters: an [OnChainTx.BSDK] renders as [TxHistoryDetailsUM.SingleAsset] (see
* [OnChainTxToDetailsUMConverter]) while an [ExpressTx] (swap / onramp) renders as [TxHistoryDetailsUM.TwoAssets] (see
* [ExpressTxToDetailsUMConverter]). The header overflow menu is built once here and shared by both.
*/
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(
currency: CryptoCurrency,
onCopyAddress: (String) -> Unit,
onGoToProvider: (String) -> Unit,
onCopyTxId: (() -> Unit)? = null,
onShare: (() -> Unit)? = null,
onExplore: (() -> Unit)? = null,
lookup: TxHistoryLookupContext = TxHistoryLookupContext(
ownAccountByNetwork = emptyMap(),
isAccountsModeEnabled = false,
walletInfoById = emptyMap(),
),
validatorsByAddress: Map<String, Yield.Validator> = emptyMap(),
onOpenValidator: (String) -> Unit = {},
) : Converter<TxHistoryInfo, TxHistoryDetailsUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter()
private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter()
private val menu = buildDetailsMenu(onCopyTxId, onShare, onExplore)
/** Own deposit addresses on the viewed currency's network — drives the on-chain own-vs-external transfer title. */
private val ownAddresses: Set<String> =
lookup.ownAccountByNetwork[currency.network.id.rawId]?.keys.orEmpty()
private val onChainConverter = OnChainTxToDetailsUMConverter(
currency = currency,
onCopyAddress = onCopyAddress,
menu = menu,
validatorsByAddress = validatorsByAddress,
onOpenValidator = onOpenValidator,
// Own deposit addresses on the viewed currency's network — drives the on-chain own-vs-external transfer title.
ownAddresses = lookup.ownAccountByNetwork[currency.network.id.rawId]?.keys.orEmpty(),
)
private val expressConverter = ExpressTxToDetailsUMConverter(
onGoToProvider = onGoToProvider,
lookup = lookup,
menu = menu,
)
override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) {
is OnChainTx.BSDK -> convertOnChain(value.txInfo)
is ExpressTx.Swap -> convertExpressSwap(value)
is ExpressTx.Onramp -> convertExpressOnramp(value)
is OnChainTx.BSDK -> onChainConverter.convert(value.txInfo)
is ExpressTx -> expressConverter.convert(value)
}
// region On-chain (TxInfo)
/**
* Every on-chain row renders as [TxHistoryDetailsUM.SingleAsset]. A two-asset swap always surfaces as
* [ExpressTx.Swap] (handled separately); an on-chain `TxInfo` of type `Swap` (e.g. a DEX swap with no express
* record) carries no legs, so it falls back to the single amount it does have rather than an empty two-asset card.
*/
private fun convertOnChain(value: TxInfo): TxHistoryDetailsUM = TxHistoryDetailsUM.SingleAsset(
header = value.toHeaderUM(),
amountBlock = value.toAmountBlockUM(),
counterparty = value.toCounterpartyUM(),
// Network fee from the tx itself; rate is not surfaced (no data).
rows = value.toInfoRows(),
)
private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM(
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)),
isFailed = status is TxInfo.TransactionStatus.Failed,
)
/**
* Counterparty card ("Recipient" / "From"). Only the external-address avatar is produced built from the `User`
* interaction address (the same source the history list uses for its external-address subtitle); a counterparty that
* is not a plain external `User` address yields no card (`null`).
*
* The [lookup] needed to resolve an own-account / own-wallet avatar here is already available (it drives the
* swap/onramp leg owners), but applying it to the single-asset counterparty card is intentionally out of scope for
* now a follow-up.
*/
private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? {
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
return TxHistoryDetailsUM.CounterpartyUM(
label = counterpartyLabel(),
title = stringReference(address.toBriefAddressFormat()),
avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address),
onCopyClick = { onCopyAddress(address) },
)
}
/** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */
private fun TxInfo.counterpartyLabel(): TextReference =
if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from)
private fun TxInfo.headerTitle(): TextReference = when (type) {
is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped)
is TransactionType.Transfer -> transferTitle()
else -> stringReference(type.toString())
}
/**
* Transfer header label, mirroring the history row: a transfer between the user's own accounts/wallets reads
* "Transfer", an outgoing transfer to an external address "Send", an incoming one "Receive" (status-aware).
*/
private fun TxInfo.transferTitle(): TextReference {
val counterpartyAddress = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address
val isOwnTransfer = counterpartyAddress != null && counterpartyAddress in ownAddresses
return when {
isOwnTransfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
isOutgoing -> statusAwareTitle(R.string.common_sending, R.string.common_sent)
else -> statusAwareTitle(R.string.common_receiving, R.string.common_received)
}
}
// endregion
// region Express (swap / onramp)
/**
* The two-asset block always renders the deal's `fromAsset``toAsset` regardless of [ExpressTx.Swap.isOutgoing]
* `isOutgoing` only selects which leg is the *viewed* one in the history row, it does not reorder the detail legs.
*/
private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets {
val status = exchangeStatusConverter.convert(swap.tx.status)
val fromOwner = resolveLegOwner(swap.tx.fromAddress, swap.tx.fromAsset.cryptoCurrency)
val toOwner = resolveLegOwner(swap.tx.payoutAddress, swap.tx.toAsset.cryptoCurrency)
return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
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),
owner = fromOwner,
sign = status.outgoingSign(),
isFaded = status is Status.Failed,
),
to = swap.tx.toAsset.toAssetUM(
label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to),
owner = toOwner,
sign = status.incomingSign(),
isFaded = status is Status.Failed,
),
statusBanner = swap.tx.status.toStatusBannerUM(),
rows = swap.toInfoRows(onProviderClick = swap.providerClick(), rateRow = swap.tx.swapRateRow()),
providerButton = providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()),
)
}
private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets {
val status = onrampStatusConverter.convert(onramp.tx.status)
val toOwner = resolveLegOwner(onramp.tx.payoutAddress, onramp.tx.toAsset.cryptoCurrency)
return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
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.
label = resourceReference(R.string.tx_history_you_paid),
isFaded = status is Status.Failed,
),
to = onramp.tx.toAsset.toAssetUM(
label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to),
owner = toOwner,
sign = status.incomingSign(),
isFaded = status is Status.Failed,
),
statusBanner = onramp.tx.status.toStatusBannerUM(),
rows = onramp.toInfoRows(onProviderClick = onramp.providerClick(), rateRow = onramp.tx.onrampRateRow()),
providerButton = providerButton(onramp.externalTxUrl, onramp.tx.status.providerButtonLabel()),
)
}
/**
* Resolves a swap/onramp leg's [address] (on the leg currency's network) to the owner shown under the amount:
* the user's own account / wallet, or the external [TxHistoryDetailsUM.AssetOwnerUM.Address] (e.g. a send-and-swap
* payout). `null` when there is no address to resolve (e.g. the very-old-version missing `fromAddress`, onramp fiat).
*/
private fun resolveLegOwner(address: String?, legCurrency: CryptoCurrency?): TxHistoryDetailsUM.AssetOwnerUM? {
if (address == null) return null
return when (val resolved = lookup.resolveOwner(address, legCurrency?.network?.id?.rawId)) {
is ResolvedOwner.OwnAccount -> TxHistoryDetailsUM.AssetOwnerUM.Account(
name = resolved.account.accountName.toUM().value,
iconResId = resolved.account.icon.value.getResId(),
backgroundColor = resolved.account.icon.color.getUiColor(),
)
is ResolvedOwner.OwnWallet -> TxHistoryDetailsUM.AssetOwnerUM.Wallet(
name = stringReference(resolved.walletInfo.name),
deviceIconUM = resolved.walletInfo.deviceIconUM,
)
is ResolvedOwner.External -> TxHistoryDetailsUM.AssetOwnerUM.Address(
name = stringReference(resolved.address.toBriefAddressFormat()),
rawAddress = resolved.address,
)
}
}
/** Leg caption: the direction-only [fallback] ("You send" / "You receive") without an owner, "From" / "To" with one. */
private fun ownerLabel(
owner: TxHistoryDetailsUM.AssetOwnerUM?,
@StringRes fallback: Int,
@StringRes owned: Int,
): TextReference = resourceReference(if (owner != null) owned else fallback)
/** Opens the deal's provider page on tap; `null` when the deal has no provider link. */
private fun ExpressTx.providerClick(): (() -> Unit)? = externalTxUrl?.let { url -> { onGoToProvider(url) } }
private fun providerButton(url: String?, @StringRes label: Int?): TxHistoryDetailsUM.ProviderButtonUM? {
if (url == null || label == null) return null
return TxHistoryDetailsUM.ProviderButtonUM(
text = resourceReference(label),
onClick = { onGoToProvider(url) },
)
}
/**
* Builds one crypto leg of the two-asset block. The ticker symbol and icon come from the resolved
* [ExpressTransactionAsset.cryptoCurrency]; when it is unresolved the symbol falls back to the network id and the
* icon slot is left empty ([currencyIcon] = `null`).
*/
private fun ExpressTransactionAsset.toAssetUM(
label: TextReference,
owner: TxHistoryDetailsUM.AssetOwnerUM?,
sign: String,
isFaded: Boolean,
): TxHistoryDetailsUM.AssetUM {
val symbol = displaySymbol
val formatted = amount.format { crypto(
symbol = symbol,
decimals = decimals,
ignoreSymbolPosition = true,
) }.trim()
return TxHistoryDetailsUM.AssetUM(
label = label,
owner = owner,
amount = stringReference((sign + formatted).trim()),
currencyIcon = cryptoCurrency?.let(iconStateConverter::convert),
isFaded = isFaded,
)
}
/**
* Builds the fiat ("You paid") leg of an onramp. The paid fiat amount is exact and carries no sign neither `+`/``
* 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 = fiatCode
val formatted = (value ?: BigDecimal.ZERO)
.format { fiat(fiatCurrencyCode = code, fiatCurrencySymbol = currencySymbol) }
return TxHistoryDetailsUM.AssetUM(
label = label,
owner = null,
amount = stringReference(formatted.trim()),
currencyIcon = null,
isFaded = isFaded,
)
}
// endregion
}
// region Status helpers
/**
* Express swap status the status plaque under the two-asset block.
*
* In-flight stages render as [Severity.Info] with the rotating loader; [Verifying][ExpressExchangeStatus.Verifying]
* (KYC) and the paused / refunded terminals as [Severity.Warning]; the failure terminals as [Severity.Error]; the
* [Finished][ExpressExchangeStatus.Finished] success as [Severity.Success] (the plaque then auto-collapses see
* `TxHistoryDetailsStatusBanner`). [Unknown][ExpressExchangeStatus.Unknown] carries nothing to show, so it hides the
* plaque (`null`).
*/
private fun ExpressExchangeStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) {
ExpressExchangeStatus.Preview,
ExpressExchangeStatus.Created,
ExpressExchangeStatus.ExchangeTxSent,
ExpressExchangeStatus.Waiting,
-> loadingBanner(R.string.express_exchange_status_receiving_active)
ExpressExchangeStatus.WaitingTxHash -> loadingBanner(R.string.express_exchange_status_waiting_tx_hash)
ExpressExchangeStatus.Confirming -> loadingBanner(R.string.express_exchange_status_confirming_active)
ExpressExchangeStatus.Exchanging -> loadingBanner(R.string.express_exchange_status_exchanging_active)
ExpressExchangeStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active)
ExpressExchangeStatus.Verifying -> verificationBanner()
ExpressExchangeStatus.Refunded -> warningBanner(R.string.express_exchange_status_refunded)
ExpressExchangeStatus.Paused -> warningBanner(R.string.express_exchange_status_paused)
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
-> failedBanner()
ExpressExchangeStatus.Expired -> errorBanner(R.string.express_exchange_status_failed)
ExpressExchangeStatus.Finished -> successBanner(R.string.express_exchange_status_exchanged)
ExpressExchangeStatus.Unknown -> null
}
/**
* Express onramp status the status plaque under the two-asset block. Same severity mapping as the swap variant; the
* [Finished][ExpressOnrampStatus.Finished] success ("Purchase completed") is the only [Severity.Success] (auto-collapsed).
*/
private fun ExpressOnrampStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) {
ExpressOnrampStatus.Created,
ExpressOnrampStatus.WaitingForPayment,
-> loadingBanner(R.string.express_exchange_status_receiving_active)
ExpressOnrampStatus.PaymentProcessing -> loadingBanner(R.string.express_exchange_status_confirming_active)
ExpressOnrampStatus.Verifying -> verificationBanner()
ExpressOnrampStatus.Paid -> loadingBanner(R.string.express_exchange_status_buying_active)
ExpressOnrampStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active)
ExpressOnrampStatus.Paused -> warningBanner(R.string.express_exchange_status_paused)
ExpressOnrampStatus.Failed -> failedBanner()
ExpressOnrampStatus.Expired -> errorBanner(R.string.express_exchange_status_failed)
ExpressOnrampStatus.Finished -> successBanner(R.string.express_exchange_status_bought)
ExpressOnrampStatus.Unknown -> null
}
/**
* Label of the bottom CTA for an express swap, or `null` for statuses that need no provider action. The KYC
* [Verifying][ExpressExchangeStatus.Verifying] state sends the user to verification; the failure terminals send them
* to the provider (to track / refund). Mirrors the failed/verification banners (the existing express block uses the
* same per-tx link for both).
*/
@StringRes
private fun ExpressExchangeStatus.providerButtonLabel(): Int? = when (this) {
ExpressExchangeStatus.Verifying -> R.string.common_go_to_verification
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
ExpressExchangeStatus.Expired,
-> R.string.common_go_to_provider
else -> null
}
/** Label of the bottom CTA for an express onramp, or `null` for statuses that need no provider action. */
@StringRes
private fun ExpressOnrampStatus.providerButtonLabel(): Int? = when (this) {
ExpressOnrampStatus.Verifying -> R.string.common_go_to_verification
ExpressOnrampStatus.Failed,
ExpressOnrampStatus.Expired,
-> R.string.common_go_to_provider
else -> null
}
private fun loadingBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(title),
isLoading = true,
)
private fun successBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Success,
title = resourceReference(title),
isLoading = false,
)
private fun warningBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Warning,
title = resourceReference(title),
isLoading = false,
)
private fun errorBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Error,
title = resourceReference(title),
isLoading = false,
)
/** Failure terminal: red plaque with the shared "visit provider to refund" hint. */
private fun failedBanner() = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
)
/** KYC verification: amber plaque with the "visit provider for verification" hint. */
private fun verificationBanner() = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Warning,
title = resourceReference(R.string.express_exchange_status_verifying),
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
isLoading = false,
)
// endregion
// region Info rows (provider / rate / network fee)
/** Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). */
private fun TxInfo.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = listOfNotNull(feeRow()).toImmutableList()
/**
* Detail rows of an express op, in order: the [provider] row (its name), the effective-[rateRow] row, then the
* network-fee row from the matched on-chain leg. Each is dropped when its data is absent the provider while it is
* unresolved, the rate while an amount is missing / non-positive (see [swapRateRow] / [onrampRateRow]), the fee while
* no on-chain leg / fee is present.
*/
private fun ExpressTx.toInfoRows(
onProviderClick: (() -> Unit)?,
rateRow: TxHistoryDetailsUM.InfoRowUM?,
): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = buildList {
provider?.let { add(it.providerRow(onProviderClick)) }
rateRow?.let { add(it) }
addAll(txInfo.toInfoRows())
}.toImmutableList()
private fun ExpressProvider.providerRow(onClick: (() -> Unit)?): TxHistoryDetailsUM.InfoRowUM =
TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.express_provider),
value = stringReference(name),
// The arrow link affordance is shown only when the row opens the provider page.
trailingIconRes = onClick?.let { R.drawable.ic_arrow_top_right_24 },
onClick = onClick,
)
/** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */
private fun OnChainTx?.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> =
(this as? OnChainTx.BSDK)?.txInfo?.toInfoRows() ?: persistentListOf()
private fun TxInfo.feeRow(): TxHistoryDetailsUM.InfoRowUM? {
val fee = fee ?: return null
val value = fee.value ?: return null
return TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.common_network_fee_title),
value = stringReference(
value.format { crypto(symbol = fee.currencySymbol, decimals = fee.decimals, ignoreSymbolPosition = true) },
),
)
}
// endregion
// region Rate row
private const val RATE_MAX_DECIMALS = 8
private const val RATE_IF_ZERO_DECIMALS = 2
/**
* Effective swap rate row `1 {from} {x} {to}`, computed on the fly as `x = toAmount / fromAmount` (`toAmount` is
* already the actual-or-expected payout the data layer coalesces `actualAmount ?: amount`). Hidden (`null`) when an
* amount is missing or non-positive there is then no rate to show and division by zero is avoided.
*/
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.displaySymbol
val quoteSymbol = toAsset.displaySymbol
val value = rateText(
base = oneOf(baseSymbol),
quote = rate.format { crypto(symbol = quoteSymbol, decimals = toAsset.decimals, ignoreSymbolPosition = true) },
)
return rateRowUM(value)
}
/**
* Effective onramp rate row `1 {crypto} {x} {fiat}`, computed on the fly as `x = fiatPaid / cryptoReceived`. The API's
* nominal `rate` / `rate_usd` are intentionally ignored to avoid UI drift from hidden fees. Hidden (`null`) when an
* amount is missing or non-positive.
*/
private fun OnrampTransaction.onrampRateRow(): TxHistoryDetailsUM.InfoRowUM? {
val fiatPaid = fromFiat.value.takeIfPositive() ?: return null
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.displaySymbol
val fiatCode = fromFiat.fiatCode
val value = rateText(
base = oneOf(cryptoSymbol),
quote = rate.format { fiat(fiatCurrencyCode = fiatCode, fiatCurrencySymbol = fromFiat.currencySymbol) },
)
return rateRowUM(value)
}
private fun rateRowUM(value: String): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.common_rate),
value = stringReference(value),
)
/** Division scale: the quote's decimals, capped at [RATE_MAX_DECIMALS]; a zero-decimal quote still shows two. */
private fun rateScale(quoteDecimals: Int): Int =
(if (quoteDecimals == 0) RATE_IF_ZERO_DECIMALS else quoteDecimals).coerceAtMost(RATE_MAX_DECIMALS)
/**
* Leading `1 {symbol}` of the rate, e.g. `1 POL` number-first, matching the amount legs (the crypto formatter forces a
* two-decimal minimum, so the literal `1` is built directly rather than via [crypto]).
*/
private fun oneOf(symbol: String): String = "1${StringsSigns.NON_BREAKING_SPACE}$symbol"
private fun rateText(base: String, quote: String): String {
return "${base.trim()} ${StringsSigns.APPROXIMATE} ${quote.trim()}"
}
private fun BigDecimal?.takeIfPositive(): BigDecimal? = this?.takeIf { it > BigDecimal.ZERO }
// endregion
// region Amount building helpers
/** Leading sign of the pay-in / "You send" leg: `` while in flight or settled, dropped on a failed deal. */
private fun Status.outgoingSign(): String = if (this is Status.Failed) "" else "${StringsSigns.MINUS} "
/**
* Leading sign of the payout / "You receive" leg: `~` while in flight (the final received amount is still an estimate),
* `+` once the funds have settled, and dropped on a failed deal (the amount is then only struck through).
*/
private fun Status.incomingSign(): String = when (this) {
is Status.Unconfirmed -> "${StringsSigns.TILDE_SIGN} "
is Status.Confirmed -> "${StringsSigns.PLUS} "
is Status.Failed -> ""
}
/**
* Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+`
* otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) the UI then only
* strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed].
*/
private fun TxInfo.signedAmount(currency: CryptoCurrency): String {
val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) }
val prefix = when {
status is TxInfo.TransactionStatus.Failed -> ""
amount.isZero() -> ""
isOutgoing -> "${StringsSigns.MINUS} "
else -> "${StringsSigns.PLUS} "
}
return (prefix + formatted).trim()
}
// endregion
// 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(): 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 {
val dateTime = DateTime(timestampMillis)
val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime)
val time = DateTimeFormatters.timeFormatter.print(dateTime)
return stringReference("$date, $time")
}
// endregion
}

View file

@ -9,7 +9,6 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Conten
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
@ -51,6 +50,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
private val lookupContext: TxHistoryLookupContext? = null,
) : Converter<TxInfo, TransactionItemUM> {
private val titleConverter = TxHistoryTitleConverter()
private val pillConverter = TxHistoryStatusPillConverter(currency, txHistoryUiActions)
@Suppress("CyclomaticComplexMethod")
@ -69,7 +69,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
// endregion
// region Content
is TransactionType.Operation -> operationContent(value, uiStatus, type)
is TransactionType.Operation -> operationContent(value, uiStatus)
is TransactionType.Swap -> swapContent(value, uiStatus)
is TransactionType.Transfer -> transferContent(value, uiStatus)
is TransactionType.Staking.ClaimRewards -> claimRewardsContent(value, uiStatus)
@ -84,23 +84,20 @@ internal class TxHistoryItemToTransactionItemUMConverter(
}
}
private fun operationContent(
tx: TxInfo,
uiStatus: TransactionItemUM.Content.Status,
type: TransactionType.Operation,
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = stringReference(type.name),
icon = tx.directionalIcon(),
subtitle = tx.extractAddressSubtitle(),
)
private fun operationContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
buildContent(
tx = tx,
uiStatus = uiStatus,
title = titleConverter.convert(tx),
icon = TxIcon.Vector(Icons.ic_document_20),
subtitle = tx.extractAddressSubtitle(),
)
private fun swapContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
buildContent(
tx = tx,
uiStatus = uiStatus,
title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
title = titleConverter.convert(tx),
icon = tx.directionalIcon(),
subtitle = tx.extractAddressSubtitle(),
)
@ -117,11 +114,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
)
}
val title = when {
ownSubtitle != null -> tx.statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
tx.isOutgoing -> tx.statusAwareTitle(R.string.common_sending, R.string.common_sent)
else -> tx.statusAwareTitle(R.string.common_receiving, R.string.common_received)
}
val title = titleConverter.convert(tx, isOwnTransfer = ownSubtitle != null)
val subtitle = ownSubtitle ?: when {
counterpartyAddress != null -> ContentSubtitle.ExternalAddress(
@ -145,10 +138,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
buildContent(
tx = tx,
uiStatus = uiStatus,
title = tx.statusAwareTitle(
pending = R.string.transaction_history_claiming_reward,
confirmed = R.string.transaction_history_staking_reward,
),
title = titleConverter.convert(tx),
icon = TxIcon.Res(R.drawable.ic_transaction_history_claim_rewards_24),
subtitle = ContentSubtitle.Plain(resourceReference(R.string.transaction_history_earned_from_stake)),
)
@ -160,7 +150,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_topup),
title = titleConverter.convert(tx),
icon = tx.directionalIcon(),
subtitle = tx.yieldSupplySubtitle(currency, type),
)
@ -172,7 +162,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_deploy_contract),
title = titleConverter.convert(tx),
icon = TxIcon.Vector(Icons.ic_document_20),
subtitle = tx.yieldSupplySubtitle(currency, type),
)
@ -184,7 +174,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_initialize),
title = titleConverter.convert(tx),
icon = TxIcon.Res(R.drawable.ic_gear_24),
subtitle = tx.yieldSupplySubtitle(currency, type),
)
@ -196,7 +186,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_reactivate),
title = titleConverter.convert(tx),
icon = TxIcon.Vector(Icons.ic_arrow_refresh_20),
subtitle = tx.yieldSupplySubtitle(currency, type),
)
@ -208,11 +198,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = if (type.isYieldSupplyWithdraw || tx.isOutgoing) {
resourceReference(R.string.yield_module_transaction_withdraw)
} else {
resourceReference(R.string.common_transfer)
},
title = titleConverter.convert(tx),
icon = tx.directionalIcon(),
subtitle = tx.yieldSupplySubtitle(currency, type),
hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing,
@ -224,7 +210,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
): TransactionItemUM.Content = buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.transaction_history_operation),
title = titleConverter.convert(tx),
icon = tx.directionalIcon(),
subtitle = tx.extractAddressSubtitle(),
)
@ -233,7 +219,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
buildContent(
tx = tx,
uiStatus = uiStatus,
title = resourceReference(R.string.gasless_transaction_fee),
title = titleConverter.convert(tx),
icon = tx.directionalIcon(),
subtitle = tx.extractAddressSubtitle(),
)

View file

@ -146,7 +146,7 @@ private fun TxInfo.buildPillSubtitle(status: TransactionItemUM.Content.Status):
)
}
private fun PillLabels.resolve(status: TransactionItemUM.Content.Status): TextReference = when (status) {
internal fun PillLabels.resolve(status: TransactionItemUM.Content.Status): TextReference = when (status) {
is TransactionItemUM.Content.Status.Confirmed -> resourceReference(confirmed)
is TransactionItemUM.Content.Status.Unconfirmed -> resourceReference(pending)
is TransactionItemUM.Content.Status.Failed -> if (hasFailedTemplate) {

View file

@ -0,0 +1,70 @@
package com.tangem.features.txhistory.converter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.impl.R
/**
* Single source of truth for an on-chain [TxInfo]'s human-readable title, dispatched on [TransactionType].
*
* Shared by the history list (row content title and status-pill label) and the transaction-details header, so the two
* never drift apart. Pill types (Approve / Staking.* / YieldSupply Enter|Exit) resolve to the status-aware pill label
* text only the same wording shown in the list pill, without its trailing amount (e.g. "Staking", "Restaking",
* "Yield mode"). Content types reproduce the per-type row titles.
*/
internal class TxHistoryTitleConverter {
/**
* @param tx transaction whose title to resolve.
* @param isOwnTransfer for a [TransactionType.Transfer] only whether the counterparty is one of the user's own
* accounts/wallets, which selects the "Transfer" title over "Send" / "Receive".
*/
@Suppress("CyclomaticComplexMethod", "LongMethod")
fun convert(tx: TxInfo, isOwnTransfer: Boolean = false): TextReference = when (val type = tx.type) {
// region Pills — the label text only (no amount), matching what the list pill shows
is TransactionType.Approve -> tx.pillTitle(ApproveSpec)
is TransactionType.Staking.Stake -> tx.pillTitle(StakeSpec)
is TransactionType.Staking.Unstake -> tx.pillTitle(UnstakeSpec)
is TransactionType.Staking.Restake -> tx.pillTitle(RestakeSpec)
is TransactionType.Staking.Vote -> tx.pillTitle(VoteSpec)
is TransactionType.Staking.Withdraw -> tx.pillTitle(WithdrawSpec)
is TransactionType.YieldSupply.Enter -> tx.pillTitle(YieldEnterSpec)
is TransactionType.YieldSupply.Exit -> tx.pillTitle(YieldExitSpec)
// endregion
// region Content
is TransactionType.Operation -> stringReference(type.name)
is TransactionType.Swap -> tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped)
is TransactionType.Transfer -> tx.transferTitle(isOwnTransfer)
is TransactionType.Staking.ClaimRewards -> tx.statusAwareTitle(
pending = R.string.transaction_history_claiming_reward,
confirmed = R.string.transaction_history_staking_reward,
)
is TransactionType.YieldSupply.Topup -> resourceReference(R.string.yield_module_transaction_topup)
is TransactionType.YieldSupply.Send -> if (type.isYieldSupplyWithdraw || tx.isOutgoing) {
resourceReference(R.string.yield_module_transaction_withdraw)
} else {
resourceReference(R.string.common_transfer)
}
is TransactionType.YieldSupply.DeployContract ->
resourceReference(R.string.yield_module_transaction_deploy_contract)
is TransactionType.YieldSupply.InitializeToken ->
resourceReference(R.string.yield_module_transaction_initialize)
is TransactionType.YieldSupply.ReactivateToken ->
resourceReference(R.string.yield_module_transaction_reactivate)
is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation)
is TransactionType.GaslessFee -> resourceReference(R.string.gasless_transaction_fee)
// endregion
}
private fun TxInfo.transferTitle(isOwnTransfer: Boolean): TextReference = when {
isOwnTransfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
isOutgoing -> statusAwareTitle(R.string.common_sending, R.string.common_sent)
else -> statusAwareTitle(R.string.common_receiving, R.string.common_received)
}
private fun TxInfo.pillTitle(spec: PillSpec): TextReference = spec.labels.resolve(status.toUiStatus())
}

View file

@ -7,6 +7,11 @@ 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.models.network.TxInfo
import com.tangem.domain.staking.GetYieldUseCase
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.domain.txhistory.model.explorerHash
import com.tangem.domain.txhistory.model.idToCopy
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
@ -15,10 +20,14 @@ import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@ -31,16 +40,34 @@ internal class TxHistoryDetailsModel @Inject constructor(
private val urlOpener: UrlOpener,
private val shareManager: ShareManager,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val getYieldUseCase: GetYieldUseCase,
ownerLookupProducer: TxHistoryOwnerLookupProducer,
paramsContainer: ParamsContainer,
) : Model() {
private val params: TxHistoryDetailsComponent.Params = paramsContainer.require()
/**
* Validators of the viewed currency's staking yield, keyed by on-chain address. Used to resolve the validator
* address carried by a staking [TxInfo] to its display name and page. Empty when the currency has no staking yield
* (the use case reads the prefetched yields cache and fails gracefully for non-staking / custom tokens).
*
* The yield is fetched only when the viewed tx is a staking op ([requiresValidatorLookup]) a Send / Swap / onramp
* has no validator to resolve, so it skips the use case entirely and stays on the empty map.
*/
private val validatorsByAddress: Flow<Map<String, Yield.Validator>> = params.txHistoryInfo
.map { it.requiresValidatorLookup() }
.distinctUntilChanged()
.map { requiresLookup -> if (requiresLookup) loadValidators() else emptyMap() }
.onStart { emit(emptyMap()) }
.distinctUntilChanged()
.flowOn(dispatchers.io)
val uiState: StateFlow<TxHistoryDetailsUM?> = combine(
params.txHistoryInfo,
ownerLookupProducer(),
) { txInfo, lookup ->
validatorsByAddress,
) { txInfo, lookup, validators ->
// 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 }
@ -53,11 +80,32 @@ internal class TxHistoryDetailsModel @Inject constructor(
onShare = explorerHash?.let { hash -> { share(hash) } },
onExplore = explorerHash?.let { hash -> { explore(hash) } },
lookup = lookup,
validatorsByAddress = validators,
onOpenValidator = urlOpener::openUrl,
).convert(txInfo)
}
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null)
/**
* Resolves the viewed currency's staking validators into an address-keyed map. Returns empty when the currency has
* no yield (non-staking / custom token) the use case surfaces that as a [Left] which we treat as "no validators".
*/
private suspend fun loadValidators(): Map<String, Yield.Validator> = getYieldUseCase(
cryptoCurrencyId = params.currency.id,
symbol = params.currency.symbol,
).fold(
ifLeft = { emptyMap() },
ifRight = { yield -> yield.validators.associateBy(Yield.Validator::address) },
)
/**
* Whether the row is an on-chain staking op the only case whose validator address can resolve to a validator.
* A non-staking on-chain tx, or any express row, carries no validator, so the yield lookup is skipped.
*/
private fun TxHistoryInfo.requiresValidatorLookup(): Boolean =
(this as? OnChainTx.BSDK)?.txInfo?.type is TxInfo.TransactionType.Staking
/** Copies a counterparty address to the clipboard — wired into the detail card's copy button via the converter. */
private fun onCopyAddress(address: String) {
clipboardManager.setText(text = address, isSensitive = false)

View file

@ -12,9 +12,6 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
@ -337,18 +334,12 @@ internal class TxHistoryModel @Inject constructor(
}
override fun onTransactionClick(item: TxHistoryInfo) {
// manager is non-null only under the new tx-history toggle; on the legacy path every tap falls to the explorer.
// manager is non-null only under the new tx-history toggle.
val manager = historyTxListManager
if (manager != null && item.opensInAppDetails()) {
if (manager != null) {
params.onTxDetailsRequested(manager.txHistoryInfoFlow(item))
} else {
item.explorerHash?.let(::openTxInExplorer)
}
}
}
/** On-chain transfers/swaps and every express op open the in-app details sheet; everything else goes to the explorer. */
private fun TxHistoryInfo.opensInAppDetails(): Boolean = when (this) {
is ExpressTx -> true
is OnChainTx.BSDK -> txInfo.type is TxInfo.TransactionType.Transfer || txInfo.type is TxInfo.TransactionType.Swap
}