Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 17:21:35 +03:00
commit b2da68216a
501 changed files with 16767 additions and 5720 deletions

View file

@ -67,6 +67,7 @@ dependencies {
/* Tests */
testImplementation(projects.common.test)
testImplementation(projects.domain.onramp.models)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)

View file

@ -0,0 +1,191 @@
package com.tangem.features.txhistory.converter
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.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle.Direction as SubtitleDirection
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.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.features.txhistory.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Maps an [ExpressTx] (swap / onramp) row directly to [TransactionItemUM.Content].
*
* The viewed leg is [CryptoCurrency] ([currency], the token-details currency): outgoing swap shows the pay-in
* (`from`) amount with a minus, incoming swap / onramp shows the received (`to`) amount with a plus. The 26 typed
* 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 opens the
* explorer.
*/
internal class ExpressTxToTransactionItemUMConverter(
private val currency: CryptoCurrency,
private val txHistoryUiActions: TxHistoryUiActions,
) : Converter<ExpressTx, TransactionItemUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: ExpressTx): TransactionItemUM = when (value) {
is ExpressTx.Swap -> swapContent(value)
is ExpressTx.Onramp -> onrampContent(value)
}
private fun swapContent(swap: ExpressTx.Swap): TransactionItemUM.Content {
val status = swap.tx.status.toUiStatus()
val viewedAmount = if (swap.isOutgoing) swap.tx.fromAsset.amount else swap.tx.toAsset.amount
val counterparty = if (swap.isOutgoing) swap.tx.toAsset else swap.tx.fromAsset
val prefix = when {
status is Status.Failed -> ""
swap.isOutgoing -> StringsSigns.MINUS
else -> StringsSigns.PLUS
}
return buildContent(
tx = swap,
status = status,
amount = formatAmount(viewedAmount, prefix),
direction = if (swap.isOutgoing) RowDirection.OUTGOING else RowDirection.INCOMING,
iconRes = R.drawable.ic_exchange_vertical_24,
title = swapTitle(status),
subtitle = ContentSubtitle.Asset(
direction = if (swap.isOutgoing) SubtitleDirection.TO else SubtitleDirection.FROM,
symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId,
icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert),
),
// TODO: replace null to warning logic.
warning = null,
)
}
private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content {
val status = onramp.tx.status.toUiStatus()
val prefix = when {
status is Status.Failed -> ""
status is Status.Confirmed -> StringsSigns.PLUS
else -> StringsSigns.TILDE_SIGN
}
return buildContent(
tx = onramp,
status = status,
amount = formatAmount(onramp.tx.toAsset.amount, prefix),
direction = RowDirection.INCOMING,
iconRes = R.drawable.ic_tangem_card_24,
title = onrampTitle(status),
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,
),
// TODO: replace null to warning logic.
warning = null,
)
}
@Suppress("LongParameterList")
private fun buildContent(
tx: ExpressTx,
status: Status,
amount: String?,
direction: RowDirection,
iconRes: Int,
title: TextReference,
subtitle: ContentSubtitle,
warning: TextReference?,
): TransactionItemUM.Content {
val explorerHash = tx.matchHash ?: tx.txId
return TransactionItemUM.Content(
txHash = explorerHash,
amount = amount,
currencySymbol = currency.symbol,
time = tx.timestampMillis.toTimeFormat(),
status = status,
direction = direction,
onClick = { txHistoryUiActions.openTxInExplorer(explorerHash) },
iconRes = iconRes,
title = title,
subtitle = subtitle,
timestamp = tx.timestampMillis,
warning = warning,
)
}
private fun formatAmount(amount: BigDecimal?, prefix: String): String? =
amount?.let { prefix + it.format { crypto(symbol = "", decimals = currency.decimals) }.trim() }
private fun swapTitle(status: Status): TextReference = when (status) {
is Status.Confirmed -> resourceReference(R.string.common_swapped)
is Status.Unconfirmed -> resourceReference(R.string.common_swapping)
is Status.Failed ->
resourceReference(R.string.common_action_failed, wrappedList(resourceReference(R.string.common_swapping)))
}
private fun onrampTitle(status: Status): TextReference = when (status) {
is Status.Confirmed -> resourceReference(R.string.tx_history_onramp_topped_up)
is Status.Unconfirmed -> resourceReference(R.string.tx_history_onramp_top_up)
is Status.Failed -> resourceReference(
R.string.common_action_failed,
wrappedList(resourceReference(R.string.tx_history_onramp_top_up)),
)
}
}
// region Status mapping
/**
* Collapses the typed swap status into a UI [Status] bucket: the single success state ([Finished][Confirmed]),
* the failure/return states ([Failed]/[TxFailed]/[Refunded]/[Expired]/[Unknown]) Failed, everything in flight
* (incl. [Verifying] and [Paused]) Unconfirmed.
*/
private fun ExpressExchangeStatus.toUiStatus(): Status = when (this) {
ExpressExchangeStatus.Finished -> Status.Confirmed
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
ExpressExchangeStatus.Refunded,
ExpressExchangeStatus.Expired,
ExpressExchangeStatus.Unknown,
-> Status.Failed
ExpressExchangeStatus.Preview,
ExpressExchangeStatus.Created,
ExpressExchangeStatus.ExchangeTxSent,
ExpressExchangeStatus.Waiting,
ExpressExchangeStatus.WaitingTxHash,
ExpressExchangeStatus.Confirming,
ExpressExchangeStatus.Exchanging,
ExpressExchangeStatus.Sending,
ExpressExchangeStatus.Verifying,
ExpressExchangeStatus.Paused,
-> Status.Unconfirmed
}
private fun ExpressOnrampStatus.toUiStatus(): Status = when (this) {
ExpressOnrampStatus.Finished -> Status.Confirmed
ExpressOnrampStatus.Failed,
ExpressOnrampStatus.Expired,
ExpressOnrampStatus.Unknown,
-> Status.Failed
ExpressOnrampStatus.Created,
ExpressOnrampStatus.WaitingForPayment,
ExpressOnrampStatus.PaymentProcessing,
ExpressOnrampStatus.Verifying,
ExpressOnrampStatus.Paid,
ExpressOnrampStatus.Sending,
ExpressOnrampStatus.Paused,
-> Status.Unconfirmed
}
// endregion

View file

@ -4,21 +4,20 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM
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.utils.toSyntheticTxInfo
import com.tangem.utils.converter.Converter
/**
* Converts a merged [TxHistoryInfo] row to [TransactionItemUM], delegating to the on-chain
* [TxHistoryItemToTransactionItemUMConverter]: on-chain rows convert their `TxInfo` directly, express
* rows convert a synthesized `TxInfo` view (see [toSyntheticTxInfo]).
* Converts a merged [TxHistoryInfo] row to [TransactionItemUM]: on-chain rows convert their `TxInfo` via
* [TxHistoryItemToTransactionItemUMConverter]; express rows map directly via [ExpressTxToTransactionItemUMConverter].
*/
internal class TxHistoryInfoToTransactionItemUMConverter(
private val txInfoConverter: TxHistoryItemToTransactionItemUMConverter,
private val expressConverter: ExpressTxToTransactionItemUMConverter,
) : Converter<TxHistoryInfo, TransactionItemUM> {
override fun convert(value: TxHistoryInfo): TransactionItemUM = when (value) {
is OnChainTx -> convertOnChain(value)
is ExpressTx -> txInfoConverter.convert(value.toSyntheticTxInfo())
is ExpressTx -> expressConverter.convert(value)
}
private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) {

View file

@ -14,10 +14,13 @@ import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.utils.StringsSigns
import com.tangem.utils.annotations.RemoveWithToggle
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.toBriefAddressFormat
@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]. Produces pre-redesign TransactionState.")
@RemoveWithToggle("APP_REDESIGN_ENABLED")
internal class TxHistoryItemToTransactionStateConverter(
private val currency: CryptoCurrency,
private val txHistoryUiActions: TxHistoryUiActions,

View file

@ -14,11 +14,13 @@ 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.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import com.tangem.features.txhistory.impl.R
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.persistentListOf
import org.joda.time.DateTime
/**
@ -37,13 +39,18 @@ internal class TxInfoToTxHistoryDetailsUMConverter(
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) {
is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(header = value.toHeaderUM())
// TODO([REDACTED_TASK_KEY]): populate `from` / `to` legs once TxInfo exposes the swap legs (amounts, currencies, fiat).
// Until then the card falls back to the header-only placeholder (the TwoAssetsBlock UI is already wired).
is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(
header = value.toHeaderUM(),
statusBanner = value.toStatusBannerUM(),
)
else -> TxHistoryDetailsUM.SingleAsset(
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 = emptyList(),
rows = persistentListOf(),
)
}
@ -54,6 +61,31 @@ internal class TxInfoToTxHistoryDetailsUMConverter(
subtitle = headerSubtitle(),
)
/**
* Express status plaque under the swap block. A stopgap over the three generic [TxInfo.TransactionStatus] values
* so [Severity.Warning] (verification) is not reachable yet.
*
* [REDACTED_TODO_COMMENT]
*/
private fun TxInfo.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (status) {
is TxInfo.TransactionStatus.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
)
is TxInfo.TransactionStatus.Confirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
)
is TxInfo.TransactionStatus.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 TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState
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
/**
* UI model for the in-app transaction details ("Operation") card.
@ -28,14 +29,79 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
override val header: HeaderUM,
val amountBlock: AmountBlockUM,
val counterparty: CounterpartyUM?,
val rows: List<InfoRowUM>,
val rows: ImmutableList<InfoRowUM>,
) : TxHistoryDetailsUM
/** Two-asset layout: Swap / Onramp */
/**
* 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.
*/
data class TwoAssets(
override val header: HeaderUM,
val from: AssetUM? = null,
val to: AssetUM? = null,
val statusBanner: StatusBannerUM? = null,
) : TxHistoryDetailsUM
/**
* Express status plaque under the two-asset block. The UI animates between successive emissions.
*
* @property severity Plaque colors (background tint + text/icon color).
* @property title Status line, e.g. "Awaiting funds" / "Confirmed" / "Failed".
* @property subtitle Optional second line (e.g. the refund hint on a failed terminal).
* @property isLoading `true` trailing rotating loader (in-progress); `false` static [severity] glyph.
*/
data class StatusBannerUM(
val severity: Severity,
val title: TextReference,
val subtitle: TextReference? = null,
val isLoading: Boolean,
) {
/** Visual severity of the [StatusBannerUM] — selects the background tint and the text/icon color. */
enum class Severity { Info, Success, Error, Warning }
}
/**
* 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).
*/
data class AssetUM(
val label: TextReference,
val owner: AssetOwnerUM?,
val amount: TextReference,
val currencyIcon: CurrencyIconState,
val isFaded: Boolean,
)
/**
* Counterparty rendered inline in an [AssetUM.label] when a swap leg resolves to one of the user's own portfolios.
* Carries the [name] plus a kind-specific 16dp decoration. Only own account / own wallet are decorated here (no
* address case, unlike the single-asset [CounterpartyAvatar]).
*/
@Immutable
sealed interface AssetOwnerUM {
val name: TextReference
/** User's own account — the [iconResId] glyph tinted over [backgroundColor], shown **before** the [name]. */
data class Account(
override val name: TextReference,
@DrawableRes val iconResId: Int,
val backgroundColor: Color,
) : AssetOwnerUM
/** User's own wallet — the wallet card [deviceIconUM], shown **after** the [name]. */
data class Wallet(
override val name: TextReference,
val deviceIconUM: DeviceIconUM,
) : AssetOwnerUM
}
/**
* Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto
* [amount] and the secondary [fiatAmount].
@ -43,7 +109,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* [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).
*/
@Immutable
data class AmountBlockUM(
val currencyIcon: CurrencyIconState,
val amount: TextReference,
@ -55,7 +120,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* 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].
*/
@Immutable
data class InfoRowUM(
val label: TextReference,
val value: TextReference,
@ -76,7 +140,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* @property avatar Leading avatar.
* @property onCopyClick Copy action; `null` hides the copy button (e.g. own-wallet has nothing to copy).
*/
@Immutable
data class CounterpartyUM(
val label: TextReference,
val title: TextReference,
@ -105,7 +168,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* Shared bottom-sheet top bar. The icon glyph and [title] text come from the transaction type; [status] drives
* the three visual states (in-progress / confirmed / failed) recoloring the icon circle and the title.
*/
@Immutable
data class HeaderUM(
@DrawableRes val iconRes: Int,
val status: TransactionItemUM.Content.Status,

View file

@ -28,6 +28,7 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.features.txhistory.component.TxHistoryComponent
import com.tangem.features.txhistory.converter.ExpressTxToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
@ -39,6 +40,7 @@ import com.tangem.features.txhistory.utils.HistoryTxListManager
import com.tangem.features.txhistory.utils.TxHistoryListManager
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.annotations.RemoveWithToggle
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.ImmutableList
@ -100,9 +102,11 @@ internal class TxHistoryModel @Inject constructor(
emptyFlow()
}
@RemoveWithToggle("APP_REDESIGN_ENABLED")
private val legacyTxHistoryItemConverter =
TxHistoryItemToTransactionStateConverter(currency = params.currency, txHistoryUiActions = this)
@RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED")
private val txHistoryListManager: TxHistoryListManager? = if (!txHistoryFeatureToggle.isNewTxHistoryEnabled) {
TxHistoryListManager(
repository = repository,
@ -193,7 +197,6 @@ internal class TxHistoryModel @Inject constructor(
}
}
// Temporary: express rows are mapped to UI via a synthesized TxInfo (see ExpressTx.toSyntheticTxInfo).
private fun buildUiItems(
merged: List<TxHistoryInfo>,
lookup: TxHistoryLookupContext,
@ -204,6 +207,10 @@ internal class TxHistoryModel @Inject constructor(
txHistoryUiActions = this,
lookupContext = lookup,
),
expressConverter = ExpressTxToTransactionItemUMConverter(
currency = params.currency,
txHistoryUiActions = this,
),
)
val items = mutableListOf<TxHistoryItemsUM.TxHistoryItemUM>()

View file

@ -19,8 +19,7 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
internal fun TxHistoryDetailsContent(state: TxHistoryDetailsUM, modifier: Modifier = Modifier) {
when (state) {
is TxHistoryDetailsUM.SingleAsset -> SingleAssetContent(state = state, modifier = modifier)
// TODO([REDACTED_TASK_KEY]): two-asset (Swap / Onramp) body — out of scope for the single-asset amount block ticket.
is TxHistoryDetailsUM.TwoAssets -> TwoAssetsPlaceholder(state = state, modifier = modifier)
is TxHistoryDetailsUM.TwoAssets -> TwoAssetsContent(state = state, modifier = modifier)
}
}
@ -45,6 +44,35 @@ private fun SingleAssetContent(state: TxHistoryDetailsUM.SingleAsset, modifier:
}
}
@Composable
private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) {
val from = state.from
val to = state.to
Column(modifier = modifier.fillMaxWidth().padding(bottom = 16.dp)) {
if (from != null && to != null) {
TxHistoryDetailsTwoAssetsBlock(
from = from,
to = to,
modifier = Modifier
.fillMaxWidth()
.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.
TwoAssetsPlaceholder(state = state)
}
// Express status plaque under the exchange block. The top gap is owned by the banner (inside its collapsing
// region), so only horizontal padding is applied here.
TxHistoryDetailsStatusBanner(
state = state.statusBanner,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
@Composable
private fun TwoAssetsPlaceholder(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) {
Box(

View file

@ -20,6 +20,8 @@ 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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Info-rows block of the transaction details card: a vertical list of DS3 [TangemRow]s (label on the leading side,
@ -36,7 +38,7 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM
* @param modifier Modifier applied to the list container.
*/
@Composable
internal fun TxHistoryDetailsInfoRows(rows: List<InfoRowUM>, modifier: Modifier = Modifier) {
internal fun TxHistoryDetailsInfoRows(rows: ImmutableList<InfoRowUM>, modifier: Modifier = Modifier) {
if (rows.isEmpty()) return
Column(
modifier = modifier,
@ -74,7 +76,7 @@ private fun TxHistoryDetailsInfoRowsPreview() {
) {
// Multiple rows — dividers between rows, none after the last
TxHistoryDetailsInfoRows(
rows = listOf(
rows = persistentListOf(
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")),
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
@ -83,7 +85,7 @@ private fun TxHistoryDetailsInfoRowsPreview() {
// Single row — no divider
TxHistoryDetailsInfoRows(
modifier = Modifier.padding(top = 16.dp),
rows = listOf(
rows = persistentListOf(
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)

View file

@ -14,6 +14,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
import kotlinx.collections.immutable.persistentListOf
/**
* The transaction details bottom sheet ("Operation"): the [TangemModalBottomSheet] shell shared by all transaction
@ -79,7 +80,7 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
),
onCopyClick = {},
),
rows = listOf(
rows = persistentListOf(
TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)

View file

@ -0,0 +1,311 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
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
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_error_20
import com.tangem.core.ui.res.generated.icons.ic_info_20
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
// 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
// (FAST_FADE), and the plaque grows over GROW to make room for a subtitle.
private const val DEFAULT_ANIMATION_MILLIS = 300
private const val FAST_FADE_MILLIS = 200
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
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
/** Gap between the exchange block above and the plaque; kept inside the collapsing region so it folds away cleanly. */
private val BANNER_TOP_GAP = 12.dp
/** Gap between the title row and the subtitle; lives inside the subtitle slot so it folds away when there's no line. */
private val SUBTITLE_TOP_GAP = 4.dp
/** Key for the title [AnimatedContent]: the resolved [text] plus the [severity] that selects the swap motion. */
private data class StatusBannerTitle(val text: String, val severity: Severity)
/**
* Title transition picked by the *target* severity: Info/Success slide in from the right ([titleSlide]); Warning/Error
* float up from below ([titleRise]). Both fade the old status out fully before fading the new one in.
*/
private fun titleTransition(target: Severity): ContentTransform = when (target) {
Severity.Warning, Severity.Error -> titleRise()
Severity.Info, Severity.Success -> titleSlide()
}
/** In-progress / success swap: old status fades out, new one fades in sliding from the right. */
private fun titleSlide(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
slideInHorizontally(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
) { width -> width / TITLE_SLIDE_FRACTION },
initialContentExit = fadeOut(tween(durationMillis = DEFAULT_ANIMATION_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/** Terminal warning / error swap: old status fades out, new one fades in floating up a touch from below. */
private fun titleRise(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
slideInVertically(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
) { height -> height / CONTENT_RISE_FRACTION },
initialContentExit = fadeOut(tween(durationMillis = DEFAULT_ANIMATION_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/** Trailing-slot swap (loader → glyph): loader fades out (Phase 1), then the glyph "pops" in (Phase 2). */
private fun iconSwapTransition(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = FAST_FADE_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
scaleIn(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
initialScale = ICON_ENTER_SCALE,
),
initialContentExit = fadeOut(tween(durationMillis = FAST_FADE_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/**
* Express status plaque of the Swap / Onramp transaction details, rendered under the two-asset exchange block.
*
* [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1370-114172)
*
* Two animation layers: [AnimatedVisibility] grows the plaque in from its top edge / collapses it to the bottom;
* in-place status transitions ([StatusBannerContent]) morph the title, background tint and trailing loaderglyph as
* the model re-emits the latest [state].
*
* @param state Current status to render, or `null` to hide the plaque (animated out).
* @param modifier Modifier applied to the plaque container.
*/
@Composable
internal fun TxHistoryDetailsStatusBanner(state: StatusBannerUM?, modifier: Modifier = Modifier) {
// Retain the last non-null state so content stays rendered through the exit (collapse+fade). The retained value
// only backfills the exit (when [state] is null); published in a SideEffect, not written during composition.
val lastState = remember { mutableStateOf<StatusBannerUM?>(null) }
SideEffect { if (state != null) lastState.value = state }
val content = state ?: lastState.value
AnimatedVisibility(
visible = state != null,
// 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),
exit = fadeOut(tween(DEFAULT_ANIMATION_MILLIS)) +
shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Bottom),
modifier = modifier,
) {
// Leading gap lives inside the animated region so it collapses together with the plaque (no residual margin).
content?.let { StatusBannerContent(state = it, modifier = Modifier.padding(top = BANNER_TOP_GAP)) }
}
}
@Composable
private fun StatusBannerContent(state: StatusBannerUM, modifier: Modifier = Modifier) {
val backgroundColor by animateColorAsState(
targetValue = state.severity.backgroundColor(),
// Delayed into Phase 2, so the tint starts shifting only once the old title has faded out, matching the spec.
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
label = "StatusBannerBackground",
)
val contentColor = state.severity.contentColor()
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Animate the title as the status advances. Keyed on (text, severity) so [titleTransition] picks the motion
// by target; the key also colors each content from its own severity (see [color] below).
AnimatedContent(
targetState = StatusBannerTitle(state.title.resolveReference(), state.severity),
transitionSpec = { titleTransition(target = targetState.severity) },
label = "StatusBannerTitle",
modifier = Modifier.weight(1f),
) { title ->
Text(
text = title.text,
style = TangemTheme.typography3.body.medium,
// From this title's own key, so the outgoing title fades out in its colour instead of snapping.
color = title.severity.contentColor(),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
StatusBannerTrailing(isLoading = state.isLoading, severity = state.severity)
}
// Retain the last non-null subtitle so the line stays rendered while it fades out (mirrors the retain above).
val lastSubtitle = remember { mutableStateOf<TextReference?>(null) }
SideEffect { if (state.subtitle != null) lastSubtitle.value = state.subtitle }
AnimatedVisibility(
visible = state.subtitle != null,
// The subtitle owns the plaque's growth: expandVertically opens its slot in Phase 2, then the text fades in
// a touch later so it trails the title. expandVertically (not animateContentSize) lets us delay the growth.
enter = expandVertically(
animationSpec = tween(GROW_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
expandFrom = Alignment.Top,
) + fadeIn(tween(DEFAULT_ANIMATION_MILLIS, delayMillis = SUBTITLE_DELAY_MILLIS)),
exit = shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Top) +
fadeOut(tween(DEFAULT_ANIMATION_MILLIS)),
) {
(state.subtitle ?: lastSubtitle.value)?.let { subtitle ->
Text(
text = subtitle.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = contentColor,
modifier = Modifier.padding(top = SUBTITLE_TOP_GAP),
)
}
}
}
}
/** Key for the trailing [AnimatedContent]: whether the loader or a glyph shows, plus the [severity] that tints it. */
private data class StatusBannerGlyph(val isLoading: Boolean, val severity: Severity)
/** Trailing slot: rotating loader while in progress, the static severity status glyph once terminal. */
@Composable
private fun StatusBannerTrailing(isLoading: Boolean, severity: Severity, modifier: Modifier = Modifier) {
// Keyed on (isLoading, severity) so the tint comes from each content's own key — the outgoing loader then fades
// out in its colour instead of snapping to the incoming status'.
AnimatedContent(
targetState = StatusBannerGlyph(isLoading, severity),
transitionSpec = { iconSwapTransition() },
label = "StatusBannerTrailing",
modifier = modifier,
) { glyph ->
val tint = glyph.severity.contentColor()
if (glyph.isLoading) {
TangemLoader(size = TangemLoaderSize.X20, color = tint)
} else {
Icon(
imageVector = glyph.severity.statusIcon(),
contentDescription = null,
tint = tint,
modifier = Modifier.size(20.dp),
)
}
}
}
@Composable
private fun Severity.backgroundColor(): Color = when (this) {
Severity.Info -> TangemTheme.colors3.bg.status.infoSubtle
Severity.Success -> TangemTheme.colors3.bg.status.successSubtle
Severity.Error -> TangemTheme.colors3.bg.status.errorSubtle
Severity.Warning -> TangemTheme.colors3.bg.status.warningSubtle
}
@Composable
private fun Severity.contentColor(): Color = when (this) {
Severity.Info -> TangemTheme.colors3.text.status.info
Severity.Success -> TangemTheme.colors3.text.status.success
Severity.Error -> TangemTheme.colors3.text.status.error
Severity.Warning -> TangemTheme.colors3.text.status.warning
}
private fun Severity.statusIcon() = when (this) {
Severity.Success -> Icons.ic_success_20
Severity.Error -> Icons.ic_error_20
Severity.Warning -> Icons.ic_warning_20
Severity.Info -> Icons.ic_info_20
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsStatusBannerPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Info, stringReference("Awaiting funds"), isLoading = true),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Info, stringReference("Deposit confirmed"), isLoading = true),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Success, stringReference("Confirmed"), isLoading = false),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(
severity = Severity.Error,
title = stringReference("Failed"),
subtitle = stringReference("Visit provider's website to refund your money"),
isLoading = false,
),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(
severity = Severity.Warning,
title = stringReference("Verification required"),
subtitle = stringReference("Visit provider's website to refund your money"),
isLoading = false,
),
)
}
}
}
// endregion

View file

@ -0,0 +1,300 @@
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
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
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetOwnerUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetUM
/**
* Two-asset ("exchange") block of the details card, used by Swap (and later Onramp): one `bg.tertiary` rounded cell
* with the [from] ("You sent") side over the [to] ("You receive") side, split by an inset dashed divider with a
* centered down-arrow masking the line. Each side is a [TangemRow]: label over the signed amount, avatar trailing.
*
* [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1265-87546)
*
* @param from Sent ("You sent" / "From …") side.
* @param to Received ("You receive" / "To …") side.
* @param modifier Modifier applied to the block container.
*/
@Composable
internal fun TxHistoryDetailsTwoAssetsBlock(from: AssetUM, to: AssetUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.background(TangemTheme.colors3.bg.tertiary),
) {
Column(
modifier = Modifier.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
TwoAssetsSideRow(asset = from)
DashedDivider()
TwoAssetsSideRow(asset = to)
}
// Centered exchange arrow. Both rows are equal-height, so the block center sits on the divider; the
// `bg.tertiary` chip behind the icon masks the dashed line, reproducing the Figma center gap.
Box(
modifier = Modifier
.align(Alignment.Center)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.tertiary)
.padding(4.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_down_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
modifier = Modifier.size(16.dp),
)
}
}
}
@Composable
private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) {
TangemRow(
modifier = modifier,
contentLead = TangemRowContentLead.Start,
verticalAlignment = TangemRowVerticalAlignment.Center,
titleSlot = { TwoAssetsSideLabel(label = asset.label, owner = asset.owner) },
subtitleSlot = {
Text(
text = asset.amount.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = if (asset.isFaded) {
TangemTheme.colors3.text.tertiary
} else {
TangemTheme.colors3.text.primary
},
textDecoration = if (asset.isFaded) TextDecoration.LineThrough else null,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
},
endSlot = {
TangemCurrencyIcon(
state = asset.currencyIcon,
modifier = Modifier.size(40.dp),
)
},
)
}
/**
* Caption label above a leg amount. Renders the [label] prefix ("You sent" / "You receive", or "From" / "To" when an
* [owner] is present) and, for a resolved [owner], its inline 16dp decoration in the Figma order the account avatar
* leads its name, the wallet key-card icon trails its name.
*/
@Composable
private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LabelText(text = label)
when (owner) {
is AssetOwnerUM.Account -> {
AssetOwnerIcon(owner = owner)
LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false))
}
is AssetOwnerUM.Wallet -> {
LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false))
AssetOwnerIcon(owner = owner)
}
null -> Unit
}
}
}
@Composable
private fun LabelText(text: TextReference, modifier: Modifier = Modifier) {
Text(
text = text.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
/** 16dp inline owner decoration: the account glyph over its color, or the wallet device card. */
@Composable
private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) {
val iconModifier = modifier.size(16.dp)
when (owner) {
is AssetOwnerUM.Account -> Box(
modifier = iconModifier
.clip(RoundedCornerShape(4.dp))
.background(owner.backgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = owner.iconResId),
contentDescription = null,
// staticDark == white in both themes (the constant glyph tone for a colored avatar), matching the
// white-on-color account avatar in Figma and the counterparty card / history-list account icon.
tint = TangemTheme.colors3.icon.staticDark,
modifier = Modifier.size(8.dp),
)
}
is AssetOwnerUM.Wallet -> TangemDeviceIcon(
state = owner.deviceIconUM,
modifier = iconModifier,
)
}
}
/** 1px inset dashed divider between the two sides, matching the Figma `divider` (dashed `line`). */
@Composable
private fun DashedDivider(modifier: Modifier = Modifier) {
val color = TangemTheme.colors3.border.tertiary
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(1.dp)
.drawBehind {
val stroke = 1.dp.toPx()
val y = size.height / 2f
drawLine(
color = color,
start = Offset(x = 0f, y = y),
end = Offset(x = size.width, y = y),
strokeWidth = stroke,
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(2.dp.toPx(), 4.dp.toPx()),
),
)
},
)
}
// region Preview
@Suppress("MagicNumber")
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsTwoAssetsBlockPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Plain swap (no resolved owner) — both sides settled.
TxHistoryDetailsTwoAssetsBlock(
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.
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
to = previewAsset(label = "You receive", amount = "1,800.00 POL", isFaded = true),
)
// Account -> another account (own-to-own transfer between two of the user's accounts).
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(
label = "From",
amount = "- 390 USDT",
isFaded = false,
owner = AssetOwnerUM.Account(
name = stringReference("Main account"),
iconResId = R.drawable.ic_rounded_star_24,
backgroundColor = Color(0xFF007FFF),
),
),
to = previewAsset(
label = "To",
amount = "+ 1,800.00 POL",
isFaded = false,
owner = AssetOwnerUM.Account(
name = stringReference("Family"),
iconResId = R.drawable.ic_family_24,
backgroundColor = Color(0xFF744FF1),
),
),
)
// Wallet -> another wallet (own-to-own transfer between two of the user's wallets).
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(
label = "From",
amount = "- 390 USDT",
isFaded = false,
owner = AssetOwnerUM.Wallet(
name = stringReference("Tangem 2.0"),
deviceIconUM = DeviceIconUM.Card(mainColor = Color(0xFF1E1E1E), secondColor = null),
),
),
to = previewAsset(
label = "To",
amount = "+ 1,800.00 POL",
isFaded = false,
owner = AssetOwnerUM.Wallet(
name = stringReference("My Wallet"),
deviceIconUM = DeviceIconUM.Ring(mainColor = Color(0xFF9F86FF)),
),
),
)
}
}
}
private fun previewAsset(label: String, amount: String, isFaded: Boolean, owner: AssetOwnerUM? = null) = AssetUM(
label = stringReference(label),
owner = owner,
amount = stringReference(amount),
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
isFaded = isFaded,
)
// endregion

View file

@ -1,7 +1,5 @@
package com.tangem.features.txhistory.utils
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
@ -50,48 +48,4 @@ private fun ExpressTx.withMatchedTxInfo(txInfo: TxInfo): ExpressTx {
is ExpressTx.Swap -> copy(txInfo = matched)
is ExpressTx.Onramp -> copy(txInfo = matched)
}
}
/**
* Synthesizes a [TxInfo] view of an express op so it can be rendered by the existing
* [com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter]. Rendered as a
* [TxInfo.TransactionType.Swap] for now (onramp included). The amount is the viewed-currency leg.
*/
internal fun ExpressTx.toSyntheticTxInfo(): TxInfo {
val viewedAmount = when (this) {
is ExpressTx.Swap -> if (isOutgoing) tx.fromAsset.amount else tx.toAsset.amount
is ExpressTx.Onramp -> tx.toAsset.amount
}
val isOutgoing = when (this) {
is ExpressTx.Swap -> this.isOutgoing
is ExpressTx.Onramp -> false
}
return TxInfo(
// matchHash is the on-chain hash (== the matched leg's hash, enables the explorer link); else txId.
txHash = matchHash ?: txId,
timestampInMillis = timestampMillis,
isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(address = "")),
sourceType = TxInfo.SourceType.Single(address = ""),
interactionAddressType = null,
status = toTransactionStatus(),
type = TxInfo.TransactionType.Swap,
amount = viewedAmount,
)
}
/**
* Maps the typed express status to the on-chain-shaped [TxInfo.TransactionStatus] used by the UI:
* the single success state (`Finished`) Confirmed, any other terminal state Failed, in-progress Unconfirmed.
*/
private fun ExpressTx.toTransactionStatus(): TxInfo.TransactionStatus {
val isFinished = when (this) {
is ExpressTx.Swap -> tx.status == ExpressExchangeStatus.Finished
is ExpressTx.Onramp -> tx.status == ExpressOnrampStatus.Finished
}
return when {
isFinished -> TxInfo.TransactionStatus.Confirmed
isTerminal -> TxInfo.TransactionStatus.Failed
else -> TxInfo.TransactionStatus.Unconfirmed
}
}

View file

@ -7,11 +7,14 @@ import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateCo
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.pagination.Batch
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.annotations.RemoveWithToggle
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]. Renders pre-redesign tx-history UI.")
@RemoveWithToggle("APP_REDESIGN_ENABLED")
internal class TxHistoryLegacyUiManager(
private val state: MutableStateFlow<TxHistoryListState>,
private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter,

View file

@ -16,6 +16,7 @@ import com.tangem.pagination.BatchAction
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListState
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.annotations.RemoveWithToggle
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -26,6 +27,8 @@ import kotlinx.coroutines.flow.*
private typealias TxHistoryBatchAction = BatchAction<Int, TxHistoryListConfig, Nothing>
@Suppress("LongParameterList")
@Deprecated("Remove with toggle [TxHistoryFeatureToggles.isNewTxHistoryEnabled]. Replaced by HistoryTxListManager.")
@RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED")
internal class TxHistoryListManager(
private val repository: TxHistoryRepositoryV2,
private val dispatchers: CoroutineDispatcherProvider,

View file

@ -6,8 +6,11 @@ import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.features.txhistory.entity.TxHistoryUM
import com.tangem.pagination.Batch
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.annotations.RemoveWithToggle
data class TxHistoryListState(
@Deprecated("Remove with toggle [TxHistoryFeatureToggles.isNewTxHistoryEnabled]. Used only by TxHistoryListManager.")
@RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED")
internal data class TxHistoryListState(
val status: PaginationStatus<*> = PaginationStatus.None,
val rawBatches: List<Batch<Int, PaginationWrapper<TxInfo>>> = emptyList(),
val uiBatches: List<Batch<Int, List<TxHistoryItemsUM.TxHistoryItemUM>>> = emptyList(),

View file

@ -8,11 +8,14 @@ import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMC
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
import com.tangem.pagination.Batch
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.annotations.RemoveWithToggle
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
@Deprecated("Remove with toggle [TxHistoryFeatureToggles.isNewTxHistoryEnabled]. Used only by TxHistoryListManager.")
@RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED")
internal class TxHistoryUiManager(
private val state: MutableStateFlow<TxHistoryListState>,
) {

View file

@ -0,0 +1,289 @@
package com.tangem.features.txhistory.converter
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
import com.tangem.core.ui.extensions.resourceReference
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.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.utils.TxHistoryUiActions
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressTxToTransactionItemUMConverterTest {
private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true)
private val coin: CryptoCurrency.Coin = createCoin(symbol = "ETH", decimals = 18)
private val converter = ExpressTxToTransactionItemUMConverter(
currency = coin,
txHistoryUiActions = txHistoryUiActions,
)
// region Status → bucket
@Test
fun `GIVEN every swap status WHEN convert THEN mapped to expected status bucket`() {
val cases = mapOf(
ExpressExchangeStatus.Finished to Status.Confirmed,
ExpressExchangeStatus.Failed to Status.Failed,
ExpressExchangeStatus.TxFailed to Status.Failed,
ExpressExchangeStatus.Refunded to Status.Failed,
ExpressExchangeStatus.Expired to Status.Failed,
ExpressExchangeStatus.Unknown to Status.Failed,
ExpressExchangeStatus.Preview to Status.Unconfirmed,
ExpressExchangeStatus.Created to Status.Unconfirmed,
ExpressExchangeStatus.ExchangeTxSent to Status.Unconfirmed,
ExpressExchangeStatus.Waiting to Status.Unconfirmed,
ExpressExchangeStatus.WaitingTxHash to Status.Unconfirmed,
ExpressExchangeStatus.Confirming to Status.Unconfirmed,
ExpressExchangeStatus.Exchanging to Status.Unconfirmed,
ExpressExchangeStatus.Sending to Status.Unconfirmed,
ExpressExchangeStatus.Verifying to Status.Unconfirmed,
ExpressExchangeStatus.Paused to Status.Unconfirmed,
)
// every enum entry is covered (guards against new statuses silently falling through)
assertThat(cases.keys).containsExactlyElementsIn(ExpressExchangeStatus.entries)
cases.forEach { (status, expected) ->
val result = converter.convert(createSwap(status = status)) as TransactionItemUM.Content
assertWithMessage(status.name).that(result.status).isEqualTo(expected)
}
}
@Test
fun `GIVEN every onramp status WHEN convert THEN mapped to expected status bucket`() {
val cases = mapOf(
ExpressOnrampStatus.Finished to Status.Confirmed,
ExpressOnrampStatus.Failed to Status.Failed,
ExpressOnrampStatus.Expired to Status.Failed,
ExpressOnrampStatus.Unknown to Status.Failed,
ExpressOnrampStatus.Created to Status.Unconfirmed,
ExpressOnrampStatus.WaitingForPayment to Status.Unconfirmed,
ExpressOnrampStatus.PaymentProcessing to Status.Unconfirmed,
ExpressOnrampStatus.Verifying to Status.Unconfirmed,
ExpressOnrampStatus.Paid to Status.Unconfirmed,
ExpressOnrampStatus.Sending to Status.Unconfirmed,
ExpressOnrampStatus.Paused to Status.Unconfirmed,
)
assertThat(cases.keys).containsExactlyElementsIn(ExpressOnrampStatus.entries)
cases.forEach { (status, expected) ->
val result = converter.convert(createOnramp(status = status)) as TransactionItemUM.Content
assertWithMessage(status.name).that(result.status).isEqualTo(expected)
}
}
// endregion
// region Amount sign / prefix
@Test
fun `GIVEN outgoing swap WHEN convert THEN amount is negative from-leg`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true),
) as TransactionItemUM.Content
assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.OUTGOING)
assertThat(result.amount).startsWith("-")
assertThat(result.amount).contains("1.5")
}
@Test
fun `GIVEN incoming swap WHEN convert THEN amount is positive to-leg`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = false),
) as TransactionItemUM.Content
assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.INCOMING)
assertThat(result.amount).startsWith("+")
assertThat(result.amount).contains("0.001")
}
@Test
fun `GIVEN finished onramp WHEN convert THEN amount prefixed with plus`() {
val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content
assertThat(result.amount).startsWith("+")
}
@Test
fun `GIVEN in-progress onramp WHEN convert THEN amount prefixed with tilde`() {
val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content
assertThat(result.amount).startsWith("~")
}
@Test
fun `GIVEN failed onramp WHEN convert THEN amount has no sign prefix`() {
val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Failed)) as TransactionItemUM.Content
assertThat(requireNotNull(result.amount).first())
.isIn(listOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'))
}
@Test
fun `GIVEN swap with null viewed amount WHEN convert THEN amount is null`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true, fromAmount = null),
) as TransactionItemUM.Content
assertThat(result.amount).isNull()
}
@Test
fun `GIVEN onramp with null amount WHEN convert THEN amount is null`() {
val result = converter.convert(
createOnramp(status = ExpressOnrampStatus.Sending, toAmount = null),
) as TransactionItemUM.Content
assertThat(result.amount).isNull()
}
// endregion
// region Title / subtitle / warning / click
@Test
fun `GIVEN swap statuses WHEN convert THEN status-aware title`() {
val swapping = converter.convert(createSwap(status = ExpressExchangeStatus.Waiting)) as TransactionItemUM.Content
val swapped = converter.convert(createSwap(status = ExpressExchangeStatus.Finished)) as TransactionItemUM.Content
assertThat(swapping.title).isEqualTo(resourceReference(R.string.common_swapping))
assertThat(swapped.title).isEqualTo(resourceReference(R.string.common_swapped))
}
@Test
fun `GIVEN onramp statuses WHEN convert THEN status-aware title`() {
val topUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content
val toppedUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content
assertThat(topUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_top_up))
assertThat(toppedUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_topped_up))
}
@Test
fun `GIVEN outgoing swap WHEN convert THEN subtitle shows TO counterparty ticker`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true),
) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Asset
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO)
assertThat(subtitle.symbol).isEqualTo("btc") // mock: counterparty (to-leg) networkId
}
@Test
fun `GIVEN onramp WHEN convert THEN subtitle shows FROM fiat code`() {
val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Asset
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM)
assertThat(subtitle.symbol).isEqualTo("SEK")
}
@Test
fun `GIVEN matched on-chain leg WHEN row clicked THEN opens explorer by match hash`() {
val result = converter.convert(
createSwap(status = ExpressExchangeStatus.Waiting, matchHash = "0xhash", isOutgoing = true),
) as TransactionItemUM.Content
result.onClick()
verify { txHistoryUiActions.openTxInExplorer("0xhash") }
}
// endregion
private fun createSwap(
status: ExpressExchangeStatus,
matchHash: String? = null,
isOutgoing: Boolean = true,
fromAmount: BigDecimal? = BigDecimal("1.5"),
toAmount: BigDecimal? = BigDecimal("0.001"),
) = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "tx-1",
status = status,
createdAtMillis = 100,
provider = null,
payinHash = matchHash.takeIf { isOutgoing },
payoutHash = matchHash.takeUnless { isOutgoing },
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
amount = fromAmount,
decimals = 18,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
amount = toAmount,
decimals = 8,
),
),
isOutgoing = isOutgoing,
txInfo = null,
)
private fun createOnramp(
status: ExpressOnrampStatus,
toAmount: BigDecimal? = BigDecimal("0.006339"),
) = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "tx-2",
status = status,
createdAtMillis = 100,
provider = null,
payoutHash = null,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
decimals = 2,
type = AmountType.FiatType(code = "SEK"),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = "btc", contractAddress = "0"),
amount = toAmount,
decimals = 8,
),
),
txInfo = null,
)
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
suffix = CryptoCurrency.ID.Suffix.RawID(rawId = "ethereum"),
),
network = Network(
id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None),
name = "Ethereum",
currencySymbol = symbol,
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
),
name = "Ethereum",
symbol = symbol,
decimals = decimals,
iconUrl = null,
isCustom = false,
)
}

View file

@ -161,8 +161,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
assertThat(result.subtitle).isEqualTo(
ContentSubtitle.Plain(resRef(R.string.transaction_history_earned_from_stake)),
)
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse()
}
@Test
@ -514,7 +514,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isTrue()
assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isTrue()
}
@Test
@ -528,7 +528,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isTrue()
assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isTrue()
}
@Test
@ -543,8 +543,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse()
}
@Test
@ -558,8 +558,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse()
assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse()
}
// endregion

View file

@ -102,6 +102,61 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24)
}
@Test
fun `GIVEN unconfirmed Swap WHEN convert THEN info status banner with loader`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Unconfirmed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
),
)
}
@Test
fun `GIVEN confirmed Swap WHEN convert THEN success status banner without loader`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Confirmed)
// Act
val banner = (converter.convert(tx) 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 failed Swap WHEN convert THEN error status banner with refund subtitle`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Failed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() {
// Arrange

View file

@ -85,21 +85,6 @@ internal class TxHistoryInfoMergerTest {
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
}
@Test
fun `GIVEN outgoing swap WHEN toSyntheticTxInfo THEN viewed from-leg amount and swap type`() {
// Arrange
val swap = createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting, isOutgoing = true)
// Act
val txInfo = swap.toSyntheticTxInfo()
// Assert
assertThat(txInfo.isOutgoing).isTrue()
assertThat(txInfo.amount).isEqualTo(BigDecimal("1.5"))
assertThat(txInfo.type).isEqualTo(TxInfo.TransactionType.Swap)
assertThat(txInfo.status).isEqualTo(TxInfo.TransactionStatus.Unconfirmed)
}
private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo(
txHash = txHash,
timestampInMillis = timestamp,