Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-22 20:59:27 +03:00
parent 416dd75fde
commit 6ca26f33d1
15 changed files with 795 additions and 138 deletions

View file

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

View file

@ -4,6 +4,7 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction as RowDirection
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle.Direction as SubtitleDirection
import com.tangem.core.ui.extensions.TextReference
@ -12,6 +13,8 @@ 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.utils.toTimeFormat
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.explorerHash
@ -29,7 +32,7 @@ import java.math.BigDecimal
* express statuses collapse into the three [Status] buckets (those drive title/icon/amount colors in the row UI).
*
* The counterparty ticker symbol+icon come from the resolved [ExpressTransactionAsset.cryptoCurrency] (swap);
* onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click routes through
* onramp shows the fiat code with the onramp country flag as the icon (fiat carries no `CryptoCurrency`). The row click routes through
* [TxHistoryUiActions.onTransactionClick] (express rows open the in-app details sheet).
*/
internal class ExpressTxToTransactionItemUMConverter(
@ -67,16 +70,15 @@ internal class ExpressTxToTransactionItemUMConverter(
symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId,
icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert),
),
// TODO: replace null to warning logic.
warning = null,
warning = swapWarning(swap),
)
}
private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content {
val status = onrampStatusConverter.convert(onramp.tx.status)
val prefix = when {
status is Status.Failed -> ""
status is Status.Confirmed -> StringsSigns.PLUS
val prefix = when (status) {
is Status.Failed -> ""
is Status.Confirmed -> StringsSigns.PLUS
else -> StringsSigns.TILDE_SIGN
}
return buildContent(
@ -89,11 +91,12 @@ internal class ExpressTxToTransactionItemUMConverter(
subtitle = ContentSubtitle.Asset(
direction = SubtitleDirection.FROM,
symbol = onramp.tx.fromFiat.currencySymbol,
// TODO: fiat carries no OnrampCurrency, so no icon yet — render with a fiat country flag once available.
icon = null,
icon = CurrencyIconState.FiatIcon(
url = onramp.tx.country?.image,
fallbackResId = R.drawable.ic_currency_24,
),
),
// TODO: replace null to warning logic.
warning = null,
warning = onrampWarning(onramp),
)
}
@ -143,4 +146,24 @@ internal class ExpressTxToTransactionItemUMConverter(
wrappedList(resourceReference(R.string.tx_history_onramp_top_up)),
)
}
/**
* KYC-verification warning. Other "problem" statuses (failed / refunded / expired) already surface as the red
* [Status.Failed] row title, so they need no extra warning line; only [ExpressExchangeStatus.Verifying]
* which buckets into the in-progress [Status.Unconfirmed] requires it to signal the pending user action.
*/
private fun swapWarning(swap: ExpressTx.Swap): TextReference? =
if (swap.tx.status == ExpressExchangeStatus.Verifying) {
resourceReference(R.string.express_exchange_notification_verification_title)
} else {
null
}
/** KYC-verification warning; see [swapWarning] for why failed statuses are intentionally excluded. */
private fun onrampWarning(onramp: ExpressTx.Onramp): TextReference? =
if (onramp.tx.status == ExpressOnrampStatus.Verifying) {
resourceReference(R.string.express_exchange_notification_verification_title)
} else {
null
}
}

View file

@ -8,11 +8,18 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.DateTimeFormatters
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.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
@ -23,20 +30,25 @@ 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
/**
* 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) currently
* produces a header-only [TxHistoryDetailsUM.TwoAssets] with the express status banner. The express legs (`from`/`to`
* amounts, currencies, fiat) are populated in a follow-up ([REDACTED_TASK_KEY]).
* (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]).
*/
internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
private val currency: CryptoCurrency,
private val onCopyAddress: (String) -> Unit,
/** Own deposit addresses for this currency's network — used to label own-transfers as "Transfer". */
private val ownAddresses: Set<String> = emptySet(),
) : Converter<TxHistoryInfo, TxHistoryDetailsUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
@ -60,8 +72,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
header = value.toHeaderUM(),
amountBlock = value.toAmountBlockUM(),
counterparty = value.toCounterpartyUM(),
// TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo.
rows = persistentListOf(),
// Network fee from the tx itself; rate is not surfaced (no data).
rows = value.toInfoRows(),
)
private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM(
@ -74,9 +86,6 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),
// TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded
// placeholder would show a misleading value.
fiatAmount = TextReference.EMPTY,
isFailed = status is TxInfo.TransactionStatus.Failed,
)
@ -102,10 +111,34 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
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)
return TxHistoryDetailsUM.TwoAssets(
@ -115,7 +148,18 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
subtitle = headerSubtitle(swap.timestampMillis),
),
statusBanner = status.toStatusBannerUM(),
from = swap.tx.fromAsset.toAssetUM(
label = resourceReference(R.string.swapping_from_title_v2),
sign = status.outgoingSign(),
isFaded = status is Status.Failed,
),
to = swap.tx.toAsset.toAssetUM(
label = resourceReference(R.string.swapping_to_title),
sign = status.incomingSign(),
isFaded = status is Status.Failed,
),
statusBanner = swap.tx.status.toStatusBannerUM(),
rows = swap.toInfoRows(),
)
}
@ -131,7 +175,59 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
),
subtitle = headerSubtitle(onramp.timestampMillis),
),
statusBanner = status.toStatusBannerUM(),
from = onramp.tx.fromFiat.toFiatAssetUM(
label = resourceReference(R.string.swapping_from_title_v2),
isFaded = status is Status.Failed,
),
to = onramp.tx.toAsset.toAssetUM(
label = resourceReference(R.string.swapping_to_title),
sign = status.incomingSign(),
isFaded = status is Status.Failed,
),
statusBanner = onramp.tx.status.toStatusBannerUM(),
rows = onramp.toInfoRows(),
)
}
/**
* 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,
sign: String,
isFaded: Boolean,
): TxHistoryDetailsUM.AssetUM {
val symbol = cryptoCurrency?.symbol ?: id.networkId
val formatted = amount.format { crypto(
symbol = symbol,
decimals = decimals,
ignoreSymbolPosition = true,
) }.trim()
return TxHistoryDetailsUM.AssetUM(
label = label,
owner = null,
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 = (type as? AmountType.FiatType)?.code ?: currencySymbol
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,
)
}
@ -141,30 +237,94 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
// region Status helpers
/**
* Express status plaque under the two-asset block, keyed on the collapsed UI [Status] bucket.
* Express swap status the status plaque under the two-asset block.
*
* A stopgap shared by on-chain swaps and express ops [Severity.Warning] (verification) is not reachable here yet.
* [REDACTED_TODO_COMMENT]
* 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 Status.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (this) {
is Status.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
)
is Status.Confirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
)
is Status.Failed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
)
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
}
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,
)
private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (this) {
is Status.Failed -> resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending)))
is Status.Unconfirmed -> resourceReference(pending)
@ -173,8 +333,59 @@ private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirme
// endregion
// region Info rows (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: the [provider] row (its name) followed by the network-fee row from the matched on-chain
* leg. The provider row is dropped while the provider is unresolved; the fee row while no on-chain leg / fee is present.
* (Rate is not surfaced yet no data.)
*/
private fun ExpressTx.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = buildList {
provider?.let { add(it.providerRow()) }
addAll(txInfo.toInfoRows())
}.toImmutableList()
private fun ExpressProvider.providerRow(): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.express_provider),
value = stringReference(name),
trailingIconRes = R.drawable.ic_arrow_top_right_24,
)
/** 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 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
@ -201,12 +412,6 @@ private fun TxInfo.headerIcon(): Int = when (type) {
else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
}
private fun TxInfo.headerTitle(): TextReference = when (type) {
is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped)
is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
else -> stringReference(type.toString())
}
private fun headerSubtitle(timestampMillis: Long): TextReference {
val dateTime = DateTime(timestampMillis)
val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime)

View file

@ -9,6 +9,7 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* UI model for the in-app transaction details ("Operation") card.
@ -35,15 +36,18 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
/**
* Two-asset layout: Swap / Onramp.
*
* [from] ("You sent") [to] ("You receive") exchange block. Both are nullable: the converter can't populate the
* legs yet (`TxInfo` exposes no swap amounts/currencies/fiat), so the card falls back to a header-only placeholder
* until that data lands. [statusBanner] is the express status plaque under the block, `null` until status is known.
* [from] ("You send") [to] ("You receive") exchange block. Both are nullable: when a leg cannot be built (e.g. a
* future express variant with no asset data) the card falls back to a header-only placeholder. [statusBanner] is
* the express status plaque under the block, `null` until status is known. [rows] carries the provider row (its
* name) followed by the network-fee row pulled from the matched on-chain leg (`ExpressTx.txInfo`); each is dropped
* when its data is unavailable (rate is not surfaced yet no data).
*/
data class TwoAssets(
override val header: HeaderUM,
val from: AssetUM? = null,
val to: AssetUM? = null,
val statusBanner: StatusBannerUM? = null,
val rows: ImmutableList<InfoRowUM> = persistentListOf(),
) : TxHistoryDetailsUM
/**
@ -67,14 +71,18 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
/**
* One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing
* side. [owner] `null` plain label ("You sent"); non-null "From"/"To" prefix plus the resolved own account /
* wallet decoration. [isFaded] renders the unsettled/failed amount (struck through, recolored to tertiary).
* side. [owner] `null` plain label ("You send"); non-null "From"/"To" prefix plus the resolved own account /
* wallet decoration. [isFaded] renders the failed amount (struck through, recolored to tertiary); an in-flight leg is
* not faded it carries a `~` estimate sign instead.
*
* [currencyIcon] is `null` when the leg has no icon to show the onramp fiat side carries no `CryptoCurrency` and
* no country flag is rendered (no data); the trailing icon slot is then left empty.
*/
data class AssetUM(
val label: TextReference,
val owner: AssetOwnerUM?,
val amount: TextReference,
val currencyIcon: CurrencyIconState,
val currencyIcon: CurrencyIconState?,
val isFaded: Boolean,
)
@ -106,23 +114,30 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto
* [amount] and the secondary [fiatAmount].
*
* [fiatAmount] is `null` while no fiat value is available (`TxInfo` has no fiat field yet) the fiat line is then
* omitted entirely rather than shown as a placeholder.
*
* [isFailed] drives the failed visual state the amount is struck through, recolored to tertiary and carries no
* `+`/`` sign (mirrors the status-driven recolor in the shared header).
*/
data class AmountBlockUM(
val currencyIcon: CurrencyIconState,
val amount: TextReference,
val fiatAmount: TextReference,
val fiatAmount: TextReference? = null,
val isFailed: Boolean,
)
/**
* A single info row of the details card: a [label] on the leading side and its [value] on the trailing side
* (e.g. `Network fee` `0.00056 ETH`, `Rate` `1 POL 0.36 USDT`). Rendered by [TxHistoryDetailsInfoRows].
*
* [trailingIconRes] is an optional glyph drawn after the [value] (e.g. the arrow-up-right link affordance on the
* provider row); `null` leaves the trailing slot text-only.
*/
data class InfoRowUM(
val label: TextReference,
val value: TextReference,
@DrawableRes val trailingIconRes: Int? = null,
)
/**

View file

@ -5,12 +5,16 @@ import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import 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.stateIn
@ -21,18 +25,27 @@ import javax.inject.Inject
internal class TxHistoryDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
paramsContainer: ParamsContainer,
) : Model() {
private val params: TxHistoryDetailsComponent.Params = paramsContainer.require()
private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = params.currency,
onCopyAddress = ::onCopyAddress,
)
/** Own deposit addresses for the viewed currency's network — drives the own-vs-external transfer title. */
private val ownAddressesFlow: Flow<Set<String>> = multiAccountStatusListSupplier()
.map { lists -> buildOwnAccountAddressMap(lists, params.currency.network.id.rawId).keys }
.distinctUntilChanged()
val uiState: StateFlow<TxHistoryDetailsUM?> = params.txHistoryInfo
.map(converter::convert)
val uiState: StateFlow<TxHistoryDetailsUM?> = combine(
params.txHistoryInfo,
ownAddressesFlow,
) { txInfo, ownAddresses ->
TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = params.currency,
onCopyAddress = ::onCopyAddress,
ownAddresses = ownAddresses,
).convert(txInfo)
}
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null)

View file

@ -1,7 +1,10 @@
package com.tangem.features.txhistory.model
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
/**
@ -17,4 +20,28 @@ internal data class TxHistoryLookupContext(
val walletInfoById: Map<UserWalletId, WalletInfo>,
)
internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM)
internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM)
/**
* Flattens every crypto-portfolio account of every wallet into an `address -> account` map for the network identified
* by [networkRawId]. Shared by the history list and the details screen to decide whether a transfer counterparty is one
* of the user's own accounts/wallets.
*/
internal fun buildOwnAccountAddressMap(
lists: List<AccountStatusList>,
networkRawId: Network.RawID,
): Map<String, Account.CryptoPortfolio> {
val map = mutableMapOf<String, Account.CryptoPortfolio>()
lists.forEach { accountList ->
accountList.accountStatuses
.filterCryptoPortfolio()
.forEach { status ->
status.flattenCurrencies().forEach { currencyStatus ->
if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach
val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach
map[address] = status.account
}
}
}
return map
}

View file

@ -9,16 +9,12 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
@ -92,7 +88,10 @@ internal class TxHistoryModel @Inject constructor(
)
.map { (accountLists, modeEnabled, wallets) ->
TxHistoryLookupContext(
ownAccountByAddress = buildOwnAccountAddressMap(accountLists),
ownAccountByAddress = buildOwnAccountAddressMap(
lists = accountLists,
networkRawId = params.currency.network.id.rawId,
),
isAccountsModeEnabled = modeEnabled,
walletInfoById = wallets.associate { wallet ->
wallet.walletId to WalletInfo(
@ -151,23 +150,6 @@ internal class TxHistoryModel @Inject constructor(
subscribeOnCurrencyStatusUpdates()
}
private fun buildOwnAccountAddressMap(lists: List<AccountStatusList>): Map<String, Account.CryptoPortfolio> {
val networkRawId = params.currency.network.id.rawId
val map = mutableMapOf<String, Account.CryptoPortfolio>()
lists.forEach { accountList ->
accountList.accountStatuses
.filterCryptoPortfolio()
.forEach { status: AccountStatus.CryptoPortfolio ->
status.flattenCurrencies().forEach { currencyStatus ->
if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach
val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach
map[address] = status.account
}
}
}
return map
}
private fun subscribeToUiItemChanges() {
txHistoryListManager
?.uiItems

View file

@ -18,6 +18,7 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
@ -56,17 +57,19 @@ internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountB
textAlign = TextAlign.Center,
textDecoration = if (amountBlock.isFailed) TextDecoration.LineThrough else null,
)
SpacerH(4.dp)
Text(
text = amountBlock.fiatAmount.resolveReference(),
color = if (amountBlock.isFailed) {
TangemTheme.colors3.text.tertiary
} else {
TangemTheme.colors3.text.secondary
},
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.Center,
)
amountBlock.fiatAmount?.let { fiatAmount ->
SpacerH(4.dp)
Text(
text = fiatAmount.resolveReference(),
color = if (amountBlock.isFailed) {
TangemTheme.colors3.text.tertiary
} else {
TangemTheme.colors3.text.secondary
},
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.Center,
)
}
}
}
@ -82,20 +85,23 @@ private fun TxHistoryDetailsAmountBlockPreview() {
) {
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false))
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = true))
// No fiat — the fiat line is omitted entirely.
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false, fiatAmount = null))
}
}
}
private fun previewAmountBlock(isFailed: Boolean) = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
amount = stringReference("+ 350.31 USDT"),
fiatAmount = stringReference("$350.31"),
isFailed = isFailed,
)
private fun previewAmountBlock(isFailed: Boolean, fiatAmount: TextReference? = stringReference("$350.31")) =
TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
amount = stringReference("+ 350.31 USDT"),
fiatAmount = fiatAmount,
isFailed = isFailed,
)
// endregion

View file

@ -58,8 +58,7 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi
.padding(start = 16.dp, end = 16.dp),
)
} else {
// TODO([REDACTED_TASK_KEY]): the converter cannot populate the swap legs yet (TxInfo exposes no two-leg / fiat /
// provider data). Until those fields land, fall back to the header-only placeholder.
// Safety fallback for a future express variant that yields no asset legs — render the header-only card.
TwoAssetsPlaceholder(state = state)
}
// Express status plaque under the exchange block. The top gap is owned by the banner (inside its collapsing
@ -70,6 +69,13 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
// Network fee (and later rate) pulled from the matched on-chain leg; the block is skipped when [rows] is empty.
TxHistoryDetailsInfoRows(
rows = state.rows,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
)
}
}

View file

@ -2,11 +2,17 @@ package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@ -20,6 +26,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM
import com.tangem.features.txhistory.impl.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -50,14 +57,27 @@ internal fun TxHistoryDetailsInfoRows(rows: ImmutableList<InfoRowUM>, modifier:
contentLead = TangemRowContentLead.Start,
titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) },
valueSlot = {
Text(
text = row.value.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = row.value.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
row.trailingIconRes?.let { iconRes ->
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = TangemTheme.colors3.text.secondary,
modifier = Modifier.size(20.dp),
)
}
}
},
)
}
@ -77,7 +97,11 @@ private fun TxHistoryDetailsInfoRowsPreview() {
// Multiple rows — dividers between rows, none after the last
TxHistoryDetailsInfoRows(
rows = persistentListOf(
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
InfoRowUM(
label = stringReference("Provider"),
value = stringReference("Mercuryo"),
trailingIconRes = R.drawable.ic_arrow_top_right_24,
),
InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")),
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),

View file

@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -51,6 +52,7 @@ import com.tangem.core.ui.res.generated.icons.ic_success_20
import com.tangem.core.ui.res.generated.icons.ic_warning_20
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import kotlinx.coroutines.delay
// Animation timings in ms (ProtoPie spec). The status swap is two-phase: the old status fades out, then the new one
// fades/slides in after ENTER_DELAY. Most steps run over the default duration; the trailing loader/glyph fades faster
@ -61,6 +63,9 @@ private const val GROW_MILLIS = 400
private const val ENTER_DELAY_MILLIS = DEFAULT_ANIMATION_MILLIS // phase 2 waits for the phase-1 fade-out to clear
private const val SUBTITLE_DELAY_MILLIS = ENTER_DELAY_MILLIS + 100 // subtitle trails the title
/** How long the success terminal ("Confirmed") lingers before the plaque auto-collapses — it shows only as a transition. */
private const val CONFIRMED_VISIBLE_MILLIS = 1_000L
private const val TITLE_SLIDE_FRACTION = 12 // in-progress/Success title slides in 1/12 width from the right
private const val CONTENT_RISE_FRACTION = 2 // Warning/Error title floats up 1/2 height from below
private const val ICON_ENTER_SCALE = 0.6f
@ -134,8 +139,31 @@ internal fun TxHistoryDetailsStatusBanner(state: StatusBannerUM?, modifier: Modi
SideEffect { if (state != null) lastState.value = state }
val content = state ?: lastState.value
// Auto-hide rules for the success terminal ("Confirmed"). It is the only [Severity.Success] state and must read as a
// *transition*, not a resting state: opening the details on an already-finished deal (no in-flight status was ever
// seen) shows nothing, and once it does appear it lingers only briefly before collapsing. Failure / verification
// terminals are not Success, so they stay put.
val seenNonSuccess = remember { mutableStateOf(false) }
SideEffect { if (state != null && state.severity != Severity.Success) seenNonSuccess.value = true }
val isTerminalSuccess = state?.severity == Severity.Success
val confirmedDismissed = remember { mutableStateOf(false) }
LaunchedEffect(isTerminalSuccess) {
if (isTerminalSuccess && seenNonSuccess.value) {
delay(CONFIRMED_VISIBLE_MILLIS)
confirmedDismissed.value = true
}
}
val isVisible = when {
state == null -> false
isTerminalSuccess && !seenNonSuccess.value -> false // opened already on the success terminal → never shown
isTerminalSuccess && confirmedDismissed.value -> false // "Confirmed" lingered long enough → collapse away
else -> true
}
AnimatedVisibility(
visible = state != null,
visible = isVisible,
// Fade and size share one tween so alpha and height finish together (mismatched default springs leave a jerk).
enter = fadeIn(tween(DEFAULT_ANIMATION_MILLIS)) +
expandVertically(tween(DEFAULT_ANIMATION_MILLIS), expandFrom = Alignment.Top),

View file

@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
@ -58,7 +57,7 @@ internal fun TxHistoryDetailsTopNavigation(
modifier: Modifier = Modifier,
) {
TangemTopNavigation(
modifier = modifier.padding(top = 8.dp),
modifier = modifier,
windowInsets = WindowInsets(0),
blurBackground = false,
startButton = { StatusActionIcon(iconRes = header.iconRes, status = header.status) },

View file

@ -112,10 +112,13 @@ private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) {
)
},
endSlot = {
TangemCurrencyIcon(
state = asset.currencyIcon,
modifier = Modifier.size(40.dp),
)
// The fiat leg of an onramp carries no icon (no CryptoCurrency, no country flag) — leave the slot empty.
asset.currencyIcon?.let { icon ->
TangemCurrencyIcon(
state = icon,
modifier = Modifier.size(40.dp),
)
}
},
)
}
@ -231,10 +234,11 @@ private fun TxHistoryDetailsTwoAssetsBlockPreview() {
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
to = previewAsset(label = "You receive", amount = "+ 1,800.00 POL", isFaded = false),
)
// Unsettled swap — the "You receive" side is struck through until the funds arrive.
// Unsettled swap — the "You receive" side shows the estimated amount with a `~` until the funds arrive
// (struck through is reserved for the failed state).
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
to = previewAsset(label = "You receive", amount = "1,800.00 POL", isFaded = true),
to = previewAsset(label = "You receive", amount = "~ 1,800.00 POL", isFaded = false),
)
// Account -> another account (own-to-own transfer between two of the user's accounts).
TxHistoryDetailsTwoAssetsBlock(

View file

@ -10,8 +10,12 @@ import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
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.ExpressProviderType
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.SdkAmount
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.domain.tokens.model.Amount
@ -94,9 +98,12 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
)
@Test
fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() {
fun `GIVEN incoming confirmed external Transfer WHEN convert THEN header has down icon, confirmed status, received title`() {
// Arrange
val tx = onChain(type = TransactionType.Transfer)
val tx = onChain(
type = TransactionType.Transfer,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val header = converter.convert(tx).header
@ -104,6 +111,64 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
// Assert
assertThat(header.iconRes).isEqualTo(R.drawable.ic_arrow_down_24)
assertThat(header.status).isEqualTo(TransactionItemUM.Content.Status.Confirmed)
assertThat(header.title).isEqualTo(resourceReference(R.string.common_received))
}
@Test
fun `GIVEN outgoing external Transfer WHEN convert THEN sent title`() {
// Arrange
val tx = onChain(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val header = converter.convert(tx).header
// Assert
assertThat(header.title).isEqualTo(resourceReference(R.string.common_sent))
}
@Test
fun `GIVEN incoming Transfer from own address WHEN convert THEN transferred title`() {
// Arrange — the counterparty is one of the user's own deposit addresses.
val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
ownAddresses = setOf(USER_ADDRESS),
)
val tx = onChain(
type = TransactionType.Transfer,
isOutgoing = false,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val header = ownConverter.convert(tx).header
// Assert
assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred))
}
@Test
fun `GIVEN outgoing Transfer to own address WHEN convert THEN transferred title`() {
// Arrange
val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
ownAddresses = setOf(USER_ADDRESS),
)
val tx = onChain(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val header = ownConverter.convert(tx).header
// Assert
assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred))
}
@ -238,6 +303,35 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
assertThat(copiedAddresses).containsExactly(USER_ADDRESS)
}
@Test
fun `GIVEN tx with fee WHEN convert THEN single network-fee row`() {
// Arrange
val tx = onChain(
type = TransactionType.Transfer,
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
)
// Act
val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows
// Assert
assertThat(rows).hasSize(1)
assertThat(rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title))
assertThat(rows.first().value.resolveString()).contains("ETH")
}
@Test
fun `GIVEN tx without fee WHEN convert THEN no rows`() {
// Arrange
val tx = onChain(type = TransactionType.Transfer, fee = null)
// Act
val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows
// Assert
assertThat(rows).isEmpty()
}
// endregion
// region Express (swap / onramp)
@ -253,7 +347,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
}
@Test
fun `GIVEN in-progress express swap WHEN convert THEN info status banner with loader`() {
fun `GIVEN exchanging express swap WHEN convert THEN info status banner with loader`() {
// Act
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging))
val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner
@ -262,12 +356,54 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
title = resourceReference(R.string.express_exchange_status_exchanging_active),
isLoading = true,
),
)
}
@Test
fun `GIVEN verifying express swap WHEN convert THEN warning status banner with verification subtitle`() {
// Act
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Verifying))
val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning,
title = resourceReference(R.string.express_exchange_status_verifying),
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN finished express swap WHEN convert THEN success status banner`() {
// Act
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished))
val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
),
)
}
@Test
fun `GIVEN unknown express swap WHEN convert THEN no status banner`() {
// Act
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Unknown))
// Assert — nothing to surface, the plaque is hidden.
assertThat((swap as TxHistoryDetailsUM.TwoAssets).statusBanner).isNull()
}
@Test
fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() {
// Act
@ -285,6 +421,131 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
)
}
@Test
fun `GIVEN in-progress express swap WHEN convert THEN from is minus and to is approx, neither faded`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.from?.amount?.resolveString()).startsWith("- ")
assertThat(result.from?.isFaded).isFalse()
// Receive amount is still an estimate while in flight: `~`, not `+`, and not struck through.
assertThat(result.to?.amount?.resolveString()).startsWith("~ ")
assertThat(result.to?.isFaded).isFalse()
// Counterparty (to) symbol comes from the resolved CryptoCurrency; the unresolved from leg falls back to network id.
assertThat(result.to?.currencyIcon).isNotNull()
assertThat(result.from?.currencyIcon).isNull()
}
@Test
fun `GIVEN finished express swap WHEN convert THEN to is plus and neither leg faded`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished)) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.from?.amount?.resolveString()).startsWith("- ")
assertThat(result.to?.amount?.resolveString()).startsWith("+ ")
assertThat(result.from?.isFaded).isFalse()
assertThat(result.to?.isFaded).isFalse()
}
@Test
fun `GIVEN failed express swap WHEN convert THEN both legs faded and signs dropped`() {
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.from?.isFaded).isTrue()
assertThat(result.to?.isFaded).isTrue()
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
assertThat(result.to?.amount?.resolveString()).doesNotContain("+")
}
@Test
fun `GIVEN express swap with matched on-chain leg WHEN convert THEN network-fee row from leg`() {
// Arrange
val leg = onChain(
type = TransactionType.Swap,
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
)
// Act
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg)) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.rows).hasSize(1)
assertThat(result.rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title))
}
@Test
fun `GIVEN express swap with provider WHEN convert THEN provider row with its name and link icon`() {
// Act
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Mercuryo")),
) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.rows).hasSize(1)
val providerRow = result.rows.first()
assertThat(providerRow.label).isEqualTo(resourceReference(R.string.express_provider))
assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo")
assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24)
}
@Test
fun `GIVEN express swap with provider and on-chain leg WHEN convert THEN provider row precedes network-fee row`() {
// Arrange
val leg = onChain(
type = TransactionType.Swap,
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
)
// Act
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg, provider = provider(name = "Changelly")),
) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.express_provider),
resourceReference(R.string.common_network_fee_title),
).inOrder()
}
@Test
fun `GIVEN finished express onramp WHEN convert THEN paid fiat is unsigned and topped-up crypto is plus`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets
// Assert
// "You paid" fiat carries no icon and no sign — the exact amount paid.
assertThat(result.from?.currencyIcon).isNull()
assertThat(result.from?.amount?.resolveString()).contains("SEK")
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
assertThat(result.from?.amount?.resolveString()).doesNotContain("+")
assertThat(result.from?.amount?.resolveString()).doesNotContain("~")
// Topped-up crypto leg is settled: `+`, with an icon.
assertThat(result.to?.currencyIcon).isNotNull()
assertThat(result.to?.amount?.resolveString()).startsWith("+ ")
assertThat(result.to?.isFaded).isFalse()
}
@Test
fun `GIVEN in-progress express onramp WHEN convert THEN paid fiat is unsigned and top-up crypto is approx`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Sending)) as TxHistoryDetailsUM.TwoAssets
// Assert
// "You paid" stays unsigned regardless of status.
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
assertThat(result.from?.amount?.resolveString()).doesNotContain("+")
assertThat(result.from?.amount?.resolveString()).doesNotContain("~")
assertThat(result.from?.isFaded).isFalse()
// Crypto to-be-received is an estimate while in flight: `~`, not struck through.
assertThat(result.to?.amount?.resolveString()).startsWith("~ ")
assertThat(result.to?.isFaded).isFalse()
}
@Test
fun `GIVEN finished express onramp WHEN convert THEN TwoAssets with success banner`() {
// Act
@ -295,12 +556,37 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
assertThat(result.statusBanner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
title = resourceReference(R.string.express_exchange_status_bought),
isLoading = false,
),
)
}
@Test
fun `GIVEN verifying express onramp WHEN convert THEN warning status banner with verification subtitle`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Verifying)) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.statusBanner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning,
title = resourceReference(R.string.express_exchange_status_verifying),
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN unknown express onramp WHEN convert THEN no status banner`() {
// Act
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Unknown)) as TxHistoryDetailsUM.TwoAssets
// Assert — nothing to surface, the plaque is hidden.
assertThat(result.statusBanner).isNull()
}
// endregion
private fun onChain(
@ -309,6 +595,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed,
amount: BigDecimal = BigDecimal.ONE,
interactionAddressType: TxInfo.InteractionAddressType? = null,
fee: SdkAmount? = null,
): OnChainTx.BSDK = OnChainTx.BSDK(
TxInfo(
txHash = TX_HASH,
@ -320,25 +607,49 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
status = status,
type = type,
amount = amount,
fee = fee,
),
)
private fun expressSwap(status: ExpressExchangeStatus): ExpressTx.Swap = ExpressTx.Swap(
private fun provider(name: String): ExpressProvider = ExpressProvider(
providerId = "provider-1",
name = name,
type = ExpressProviderType.CEX,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
slippage = null,
)
private fun expressSwap(
status: ExpressExchangeStatus,
isOutgoing: Boolean = true,
txInfo: OnChainTx? = null,
provider: ExpressProvider? = null,
): ExpressTx.Swap = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "swap-1",
status = status,
createdAtMillis = TIMESTAMP,
provider = null,
provider = provider,
payinHash = null,
payoutHash = null,
fromAsset = expressAsset(networkId = "ethereum", amount = BigDecimal("1.5"), decimals = 18),
toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.001"), decimals = 8),
toAsset = expressAsset(
networkId = "bitcoin",
amount = BigDecimal("0.001"),
decimals = 8,
cryptoCurrency = currency,
),
),
isOutgoing = true,
txInfo = null,
isOutgoing = isOutgoing,
txInfo = txInfo,
)
private fun expressOnramp(status: ExpressOnrampStatus): ExpressTx.Onramp = ExpressTx.Onramp(
private fun expressOnramp(
status: ExpressOnrampStatus,
txInfo: OnChainTx? = null,
): ExpressTx.Onramp = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "onramp-1",
status = status,
@ -351,16 +662,27 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
),
toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.006"), decimals = 8),
toAsset = expressAsset(
networkId = "bitcoin",
amount = BigDecimal("0.006"),
decimals = 8,
cryptoCurrency = currency,
),
),
txInfo = null,
txInfo = txInfo,
)
private fun expressAsset(networkId: String, amount: BigDecimal, decimals: Int): ExpressTransactionAsset =
private fun expressAsset(
networkId: String,
amount: BigDecimal,
decimals: Int,
cryptoCurrency: CryptoCurrency? = null,
): ExpressTransactionAsset =
ExpressTransactionAsset(
id = ExpressAssetId(networkId = networkId, contractAddress = "0"),
amount = amount,
decimals = decimals,
cryptoCurrency = cryptoCurrency,
)
private fun TextReference.resolveString(): String = (this as TextReference.Str).value