Updated on 2026-08-14
This commit is contained in:
parent
1beceb4744
commit
9bc5ee1684
11 changed files with 896 additions and 659 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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] fiat→asset), 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] fiat→asset), 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
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue