Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 14:58:52 +03:00
parent 0c1c03c266
commit d9092549aa
13 changed files with 413 additions and 28 deletions

View file

@ -22,6 +22,7 @@ class MockCryptoCurrencyFactory(private val userWallet: UserWallet.Cold = defaul
private val factory = CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains())
val bitcoin by lazy { createCoin(Blockchain.Bitcoin) }
val cardano by lazy { createCoin(blockchain = Blockchain.Cardano) }
val chia by lazy { createCoin(Blockchain.Chia) }
val ethereum by lazy { createCoin(Blockchain.Ethereum) }

View file

@ -418,6 +418,7 @@
<string name="common_privacy_policy">Privacy Policy</string>
<string name="common_range">%1$s-%2$s</string>
<string name="common_range_with_space">%1$s — %2$s</string>
<string name="common_rate">Rate</string>
<string name="common_read_more">Read more</string>
<string name="common_receive">Receive</string>
<string name="common_received">Received</string>

View file

@ -66,6 +66,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
cryptoCurrency = value.toCurrency,
),
country = value.country,
externalTxUrl = entity.externalTxUrl,
),
txInfo = null,
)
@ -100,6 +101,7 @@ private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): Excha
decimals = entity.to.decimals,
cryptoCurrency = value.toCurrency,
),
externalTxUrl = entity.externalTxUrl,
)
}

View file

@ -13,6 +13,8 @@ package com.tangem.domain.express.models
* @property payoutHash On-chain hash of the payout (to-side) leg, if known.
* @property fromAsset The asset sent.
* @property toAsset The asset received.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (CEX only).
*/
data class ExchangeTransaction(
val txId: String,
@ -23,4 +25,5 @@ data class ExchangeTransaction(
val payoutHash: String?,
val fromAsset: ExpressTransactionAsset,
val toAsset: ExpressTransactionAsset,
val externalTxUrl: String? = null,
)

View file

@ -17,6 +17,8 @@ import com.tangem.domain.tokens.model.AmountType
* @property fromFiat The fiat paid.
* @property toAsset The crypto asset received.
* @property country The country the onramp was made from; `null` if not resolved.
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
* supplies none (not provided by all providers).
*/
data class OnrampTransaction(
val txId: String,
@ -28,4 +30,5 @@ data class OnrampTransaction(
val fromFiat: Amount,
val toAsset: ExpressTransactionAsset,
val country: OnrampCountry? = null,
val externalTxUrl: String? = null,
)

View file

@ -99,6 +99,12 @@ sealed interface ExpressTx : TxHistoryInfo {
/** Provider behind this op, resolved from the local providers table by `providerId`; `null` if unknown. */
val provider: ExpressProvider?
/**
* Provider's page for this deal (tracking / refund / KYC), surfaced as the "Go to provider" CTA on the
* failed / verification terminals; `null` when the provider supplies no such link.
*/
val externalTxUrl: String?
/** Whether the deal reached a final state. Delegates to the wrapped model's typed status. */
val isTerminal: Boolean
@ -114,6 +120,7 @@ sealed interface ExpressTx : TxHistoryInfo {
override val createdAtMillis: Long get() = tx.createdAtMillis
override val matchHash: String? get() = if (isOutgoing) tx.payinHash else tx.payoutHash
override val provider: ExpressProvider? get() = tx.provider
override val externalTxUrl: String? get() = tx.externalTxUrl
override val isTerminal: Boolean get() = tx.status.isTerminal
}
@ -125,6 +132,7 @@ sealed interface ExpressTx : TxHistoryInfo {
override val createdAtMillis: Long get() = tx.createdAtMillis
override val matchHash: String? get() = tx.payoutHash
override val provider: ExpressProvider? get() = tx.provider
override val externalTxUrl: String? get() = tx.externalTxUrl
override val isTerminal: Boolean get() = tx.status.isTerminal
}
}

View file

@ -11,10 +11,12 @@ 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.ExchangeTransaction
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
@ -35,6 +37,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.joda.time.DateTime
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card.
@ -47,7 +50,7 @@ import java.math.BigDecimal
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 onGoToProvider: (String) -> Unit,
private val ownAddresses: Set<String> = emptySet(),
) : Converter<TxHistoryInfo, TxHistoryDetailsUM> {
@ -159,7 +162,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
isFaded = status is Status.Failed,
),
statusBanner = swap.tx.status.toStatusBannerUM(),
rows = swap.toInfoRows(),
rows = swap.toInfoRows(onProviderClick = swap.providerClick(), rateRow = swap.tx.swapRateRow()),
providerButton = providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()),
)
}
@ -185,7 +189,19 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
isFaded = status is Status.Failed,
),
statusBanner = onramp.tx.status.toStatusBannerUM(),
rows = onramp.toInfoRows(),
rows = onramp.toInfoRows(onProviderClick = onramp.providerClick(), rateRow = onramp.tx.onrampRateRow()),
providerButton = providerButton(onramp.externalTxUrl, onramp.tx.status.providerButtonLabel()),
)
}
/** Opens the deal's provider page on tap; `null` when the deal has no provider link. */
private fun ExpressTx.providerClick(): (() -> Unit)? = externalTxUrl?.let { url -> { onGoToProvider(url) } }
private fun providerButton(url: String?, @StringRes label: Int?): TxHistoryDetailsUM.ProviderButtonUM? {
if (url == null || label == null) return null
return TxHistoryDetailsUM.ProviderButtonUM(
text = resourceReference(label),
onClick = { onGoToProvider(url) },
)
}
@ -285,6 +301,32 @@ private fun ExpressOnrampStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBan
ExpressOnrampStatus.Unknown -> null
}
/**
* Label of the bottom CTA for an express swap, or `null` for statuses that need no provider action. The KYC
* [Verifying][ExpressExchangeStatus.Verifying] state sends the user to verification; the failure terminals send them
* to the provider (to track / refund). Mirrors the failed/verification banners (the existing express block uses the
* same per-tx link for both).
*/
@StringRes
private fun ExpressExchangeStatus.providerButtonLabel(): Int? = when (this) {
ExpressExchangeStatus.Verifying -> R.string.common_go_to_verification
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
ExpressExchangeStatus.Expired,
-> R.string.common_go_to_provider
else -> null
}
/** Label of the bottom CTA for an express onramp, or `null` for statuses that need no provider action. */
@StringRes
private fun ExpressOnrampStatus.providerButtonLabel(): Int? = when (this) {
ExpressOnrampStatus.Verifying -> R.string.common_go_to_verification
ExpressOnrampStatus.Failed,
ExpressOnrampStatus.Expired,
-> R.string.common_go_to_provider
else -> null
}
private fun loadingBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(title),
@ -333,26 +375,34 @@ private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirme
// endregion
// region Info rows (network fee)
// region Info rows (provider / rate / network fee)
/** Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). */
private fun TxInfo.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = listOfNotNull(feeRow()).toImmutableList()
/**
* Detail rows of an express op: 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.)
* Detail rows of an express op, in order: the [provider] row (its name), the effective-[rateRow] row, then the
* network-fee row from the matched on-chain leg. Each is dropped when its data is absent the provider while it is
* unresolved, the rate while an amount is missing / non-positive (see [swapRateRow] / [onrampRateRow]), the fee while
* no on-chain leg / fee is present.
*/
private fun ExpressTx.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = buildList {
provider?.let { add(it.providerRow()) }
private fun ExpressTx.toInfoRows(
onProviderClick: (() -> Unit)?,
rateRow: TxHistoryDetailsUM.InfoRowUM?,
): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = buildList {
provider?.let { add(it.providerRow(onProviderClick)) }
rateRow?.let { add(it) }
addAll(txInfo.toInfoRows())
}.toImmutableList()
private fun ExpressProvider.providerRow(): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM(
private fun ExpressProvider.providerRow(onClick: (() -> Unit)?): TxHistoryDetailsUM.InfoRowUM =
TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.express_provider),
value = stringReference(name),
trailingIconRes = R.drawable.ic_arrow_top_right_24,
)
// The arrow link affordance is shown only when the row opens the provider page.
trailingIconRes = onClick?.let { R.drawable.ic_arrow_top_right_24 },
onClick = onClick,
)
/** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */
private fun OnChainTx?.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> =
@ -371,6 +421,71 @@ private fun TxInfo.feeRow(): TxHistoryDetailsUM.InfoRowUM? {
// endregion
// region Rate row
private const val RATE_MAX_DECIMALS = 8
private const val RATE_IF_ZERO_DECIMALS = 2
/**
* Effective swap rate row `1 {from} {x} {to}`, computed on the fly as `x = toAmount / fromAmount` (`toAmount` is
* already the actual-or-expected payout the data layer coalesces `actualAmount ?: amount`). Hidden (`null`) when an
* amount is missing or non-positive there is then no rate to show and division by zero is avoided.
*/
private fun ExchangeTransaction.swapRateRow(): TxHistoryDetailsUM.InfoRowUM? {
val fromAmount = fromAsset.amount.takeIfPositive() ?: return null
val toAmount = toAsset.amount.takeIfPositive() ?: return null
val rate = toAmount.divide(fromAmount, rateScale(toAsset.decimals), RoundingMode.HALF_UP)
val baseSymbol = fromAsset.cryptoCurrency?.symbol ?: fromAsset.id.networkId
val quoteSymbol = toAsset.cryptoCurrency?.symbol ?: toAsset.id.networkId
val value = rateText(
base = oneOf(baseSymbol),
quote = rate.format { crypto(symbol = quoteSymbol, decimals = toAsset.decimals, ignoreSymbolPosition = true) },
)
return rateRowUM(value)
}
/**
* Effective onramp rate row `1 {crypto} {x} {fiat}`, computed on the fly as `x = fiatPaid / cryptoReceived`. The API's
* nominal `rate` / `rate_usd` are intentionally ignored to avoid UI drift from hidden fees. Hidden (`null`) when an
* amount is missing or non-positive.
*/
private fun OnrampTransaction.onrampRateRow(): TxHistoryDetailsUM.InfoRowUM? {
val fiatPaid = fromFiat.value.takeIfPositive() ?: return null
val cryptoReceived = toAsset.amount.takeIfPositive() ?: return null
// Divide at full precision; the fiat formatter then rounds the rate to the currency's display scale.
val rate = fiatPaid.divide(cryptoReceived, RATE_MAX_DECIMALS, RoundingMode.HALF_UP)
val cryptoSymbol = toAsset.cryptoCurrency?.symbol ?: toAsset.id.networkId
val fiatCode = (fromFiat.type as? AmountType.FiatType)?.code ?: fromFiat.currencySymbol
val value = rateText(
base = oneOf(cryptoSymbol),
quote = rate.format { fiat(fiatCurrencyCode = fiatCode, fiatCurrencySymbol = fromFiat.currencySymbol) },
)
return rateRowUM(value)
}
private fun rateRowUM(value: String): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.common_rate),
value = stringReference(value),
)
/** Division scale: the quote's decimals, capped at [RATE_MAX_DECIMALS]; a zero-decimal quote still shows two. */
private fun rateScale(quoteDecimals: Int): Int =
(if (quoteDecimals == 0) RATE_IF_ZERO_DECIMALS else quoteDecimals).coerceAtMost(RATE_MAX_DECIMALS)
/**
* Leading `1 {symbol}` of the rate, e.g. `1 POL` number-first, matching the amount legs (the crypto formatter forces a
* two-decimal minimum, so the literal `1` is built directly rather than via [crypto]).
*/
private fun oneOf(symbol: String): String = "1${StringsSigns.NON_BREAKING_SPACE}$symbol"
private fun rateText(base: String, quote: String): String {
return "${base.trim()} ${StringsSigns.APPROXIMATE} ${quote.trim()}"
}
private fun BigDecimal?.takeIfPositive(): BigDecimal? = this?.takeIf { it > BigDecimal.ZERO }
// endregion
// region Amount building helpers
/** Leading sign of the pay-in / "You send" leg: `` while in flight or settled, dropped on a failed deal. */

View file

@ -38,9 +38,10 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
*
* [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).
* the express status plaque under the block, `null` until status is known. [rows] carries, in order, the provider
* row (its name), the effective-rate row, and the network-fee row pulled from the matched on-chain leg
* (`ExpressTx.txInfo`); each is dropped when its data is unavailable. [providerButton] is the bottom "Go to
* provider" / "Go to verification" CTA, `null` unless the deal is on a provider-actionable terminal with a link.
*/
data class TwoAssets(
override val header: HeaderUM,
@ -48,6 +49,7 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
val to: AssetUM? = null,
val statusBanner: StatusBannerUM? = null,
val rows: ImmutableList<InfoRowUM> = persistentListOf(),
val providerButton: ProviderButtonUM? = null,
) : TxHistoryDetailsUM
/**
@ -69,6 +71,19 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
enum class Severity { Info, Success, Error, Warning }
}
/**
* Bottom call-to-action of the two-asset card, shown only on the provider-actionable terminals of an express deal
* (failed / expired "Go to provider"; KYC verification "Go to verification") and only when the deal carries a
* provider link. [onClick] opens that link (`ExpressTx.externalTxUrl`).
*
* @property text Button label ("Go to provider" / "Go to verification").
* @property onClick Opens the provider's page for this deal.
*/
data class ProviderButtonUM(
val text: TextReference,
val onClick: () -> Unit,
)
/**
* 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 send"); non-null "From"/"To" prefix plus the resolved own account /
@ -133,11 +148,14 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
*
* [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.
*
* [onClick] makes the row tappable (e.g. the provider row opens the provider page); `null` makes it non-interactive.
*/
data class InfoRowUM(
val label: TextReference,
val value: TextReference,
@DrawableRes val trailingIconRes: Int? = null,
val onClick: (() -> Unit)? = null,
)
/**

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
@ -25,6 +26,7 @@ import javax.inject.Inject
internal class TxHistoryDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager,
private val urlOpener: UrlOpener,
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
paramsContainer: ParamsContainer,
) : Model() {
@ -43,6 +45,7 @@ internal class TxHistoryDetailsModel @Inject constructor(
TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = params.currency,
onCopyAddress = ::onCopyAddress,
onGoToProvider = urlOpener::openUrl,
ownAddresses = ownAddresses,
).convert(txInfo)
}

View file

@ -11,8 +11,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chevron_right_20
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
@Composable
@ -76,6 +80,18 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
)
// Bottom "Go to provider" / "Go to verification" CTA — only on a provider-actionable terminal with a link.
state.providerButton?.let { providerButton ->
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
variant = TangemButton.Variant.Primary,
text = providerButton.text,
iconEnd = TangemIconUM.Icon(Icons.ic_chevron_right_20),
onClick = providerButton.onClick,
)
}
}
}

View file

@ -54,7 +54,8 @@ internal fun TxHistoryDetailsInfoRows(rows: ImmutableList<InfoRowUM>, modifier:
rows.forEachIndexed { index, row ->
TangemRow(
divider = index < lastIndex,
contentLead = TangemRowContentLead.Start,
contentLead = TangemRowContentLead.End,
onClick = row.onClick,
titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) },
valueSlot = {
Row(

View file

@ -53,6 +53,15 @@ private fun TxHistoryDetailsModalBottomSheetContentPreview() {
}
}
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = UI_MODE_NIGHT_YES)
@Composable
private fun TxHistoryDetailsModalBottomSheetContentTwoAssetsPreview() {
TangemThemePreviewRedesign {
TxHistoryDetailsModalBottomSheetContent(state = previewTwoAssets(), onDismiss = {})
}
}
/** Fully-populated single-asset state exercising every sub-view: header, amount block, counterparty and info rows. */
private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
header = TxHistoryDetailsUM.HeaderUM(
@ -85,4 +94,57 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
),
)
/** Failed swap exercising the two-asset body: both legs, the error status banner, provider link row and the CTA. */
private fun previewTwoAssets() = TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_exchange_vertical_24,
status = Status.Failed,
title = stringReference("Swap"),
subtitle = stringReference("Jan 20 2026, 9:24 PM"),
),
from = TxHistoryDetailsUM.AssetUM(
label = stringReference("You send"),
owner = null,
amount = stringReference("- 1.5 ETH"),
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
isFaded = true,
),
to = TxHistoryDetailsUM.AssetUM(
label = stringReference("You receive"),
owner = null,
amount = stringReference("+ 0.001 BTC"),
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_btc_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
isFaded = true,
),
statusBanner = TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error,
title = stringReference("Failed"),
subtitle = stringReference("Funds will be refunded by the provider"),
isLoading = false,
),
rows = persistentListOf(
TxHistoryDetailsUM.InfoRowUM(
label = stringReference("Provider"),
value = stringReference("Changelly"),
trailingIconRes = R.drawable.ic_arrow_top_right_24,
onClick = {},
),
TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
providerButton = TxHistoryDetailsUM.ProviderButtonUM(
text = stringReference("Go to provider"),
onClick = {},
),
)
// endregion

View file

@ -38,15 +38,23 @@ import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
private val currency = MockCryptoCurrencyFactory().ethereum
private val mockCurrencyFactory = MockCryptoCurrencyFactory()
private val currency = mockCurrencyFactory.ethereum
// The express payout leg: a real Bitcoin coin so the resolved symbol (BTC) matches the "bitcoin" network id.
private val bitcoin = mockCurrencyFactory.bitcoin
private val copiedAddresses = mutableListOf<String>()
private val openedUrls = mutableListOf<String>()
private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
onGoToProvider = openedUrls::add,
)
@BeforeEach
fun setUp() {
copiedAddresses.clear()
openedUrls.clear()
// The header subtitle formats the date via DateTimeFormatters -> DateFormat.getBestDateTimePattern,
// which is an Android stub on the JVM. Mirror the DateTimeFormattersTest mock so convert() runs.
mockkStatic(DateFormat::class)
@ -136,6 +144,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
onGoToProvider = openedUrls::add,
ownAddresses = setOf(USER_ADDRESS),
)
val tx = onChain(
@ -157,6 +166,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
onGoToProvider = openedUrls::add,
ownAddresses = setOf(USER_ADDRESS),
)
val tx = onChain(
@ -472,24 +482,49 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
// 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))
// Assert — no provider in the fixture, so rate then the on-chain leg's network fee.
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.common_rate),
resourceReference(R.string.common_network_fee_title),
).inOrder()
}
@Test
fun `GIVEN express swap with provider WHEN convert THEN provider row with its name and link icon`() {
fun `GIVEN express swap with provider and url WHEN convert THEN provider row links to the url`() {
// Act
val result = converter.convert(
expressSwap(
status = ExpressExchangeStatus.Finished,
provider = provider(name = "Mercuryo"),
externalTxUrl = EXTERNAL_URL,
),
) as TxHistoryDetailsUM.TwoAssets
// Assert — provider then rate (no on-chain leg, so no fee row).
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.express_provider),
resourceReference(R.string.common_rate),
).inOrder()
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)
providerRow.onClick?.invoke()
assertThat(openedUrls).containsExactly(EXTERNAL_URL)
}
@Test
fun `GIVEN express swap with provider but no url WHEN convert THEN provider row has no link`() {
// Act — the provider supplies no link (e.g. DEX), so the row is plain text.
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)
assertThat(providerRow.trailingIconRes).isNull()
assertThat(providerRow.onClick).isNull()
}
@Test
@ -508,10 +543,61 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
// Assert
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.express_provider),
resourceReference(R.string.common_rate),
resourceReference(R.string.common_network_fee_title),
).inOrder()
}
@Test
fun `GIVEN express swap with both amounts WHEN convert THEN rate row 1 from approx to follows provider`() {
// Act — no on-chain leg, so the rows are provider then rate.
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Changelly")),
) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.rows.map { it.label }).containsExactly(
resourceReference(R.string.express_provider),
resourceReference(R.string.common_rate),
).inOrder()
val rate = result.rows[1].value.resolveString()
// 0.001 BTC / 1.5 ETH ≈ 0.00066667; base falls back to the unresolved from-leg network id, quote to BTC.
assertThat(rate).startsWith("1")
assertThat(rate).contains("")
assertThat(rate).contains("ethereum")
assertThat(rate).contains("BTC")
}
@Test
fun `GIVEN express swap with non-positive amount WHEN convert THEN no rate row`() {
// Arrange — a zero pay-in makes the rate undefined; the row is dropped (division-by-zero guard).
val base = expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Changelly"))
val swap = base.copy(tx = base.tx.copy(fromAsset = base.tx.fromAsset.copy(amount = BigDecimal.ZERO)))
// Act
val result = converter.convert(swap) as TxHistoryDetailsUM.TwoAssets
// Assert — only the provider row remains.
assertThat(result.rows.map { it.label }).containsExactly(resourceReference(R.string.express_provider))
}
@Test
fun `GIVEN express onramp with both amounts WHEN convert THEN rate row 1 crypto approx fiat`() {
// Act
val result = converter.convert(
expressOnramp(status = ExpressOnrampStatus.Finished),
) as TxHistoryDetailsUM.TwoAssets
// Assert — onramp has no provider in the fixture, so the only row is the rate.
assertThat(result.rows.map { it.label }).containsExactly(resourceReference(R.string.common_rate))
val rate = result.rows.first().value.resolveString()
// 100 SEK / 0.006 BTC ≈ 16,666.67 SEK; base is the resolved crypto symbol (BTC).
assertThat(rate).startsWith("1")
assertThat(rate).contains("")
assertThat(rate).contains("BTC")
assertThat(rate).contains("SEK")
}
@Test
fun `GIVEN finished express onramp WHEN convert THEN paid fiat is unsigned and topped-up crypto is plus`() {
// Act
@ -587,6 +673,67 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
assertThat(result.statusBanner).isNull()
}
@Test
fun `GIVEN failed express swap with url WHEN convert THEN go-to-provider button opening the url`() {
// Act
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Failed, externalTxUrl = EXTERNAL_URL),
) as TxHistoryDetailsUM.TwoAssets
// Assert
val button = result.providerButton
assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_provider))
button?.onClick?.invoke()
assertThat(openedUrls).containsExactly(EXTERNAL_URL)
}
@Test
fun `GIVEN verifying express swap with url WHEN convert THEN go-to-verification button`() {
// Act
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Verifying, externalTxUrl = EXTERNAL_URL),
) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.providerButton?.text).isEqualTo(resourceReference(R.string.common_go_to_verification))
}
@Test
fun `GIVEN verifying express onramp with url WHEN convert THEN go-to-verification button opening the url`() {
// Act
val result = converter.convert(
expressOnramp(status = ExpressOnrampStatus.Verifying, externalTxUrl = EXTERNAL_URL),
) as TxHistoryDetailsUM.TwoAssets
// Assert
val button = result.providerButton
assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_verification))
button?.onClick?.invoke()
assertThat(openedUrls).containsExactly(EXTERNAL_URL)
}
@Test
fun `GIVEN failed express swap without url WHEN convert THEN no provider button`() {
// Act — the provider supplies no link (e.g. DEX), so there is nowhere to send the user.
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Failed, externalTxUrl = null),
) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.providerButton).isNull()
}
@Test
fun `GIVEN finished express swap with url WHEN convert THEN no provider button`() {
// Act — a settled success needs no provider action even when a link exists.
val result = converter.convert(
expressSwap(status = ExpressExchangeStatus.Finished, externalTxUrl = EXTERNAL_URL),
) as TxHistoryDetailsUM.TwoAssets
// Assert
assertThat(result.providerButton).isNull()
}
// endregion
private fun onChain(
@ -626,6 +773,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
isOutgoing: Boolean = true,
txInfo: OnChainTx? = null,
provider: ExpressProvider? = null,
externalTxUrl: String? = null,
): ExpressTx.Swap = ExpressTx.Swap(
tx = ExchangeTransaction(
txId = "swap-1",
@ -639,8 +787,9 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
networkId = "bitcoin",
amount = BigDecimal("0.001"),
decimals = 8,
cryptoCurrency = currency,
cryptoCurrency = bitcoin,
),
externalTxUrl = externalTxUrl,
),
isOutgoing = isOutgoing,
txInfo = txInfo,
@ -649,6 +798,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
private fun expressOnramp(
status: ExpressOnrampStatus,
txInfo: OnChainTx? = null,
externalTxUrl: String? = null,
): ExpressTx.Onramp = ExpressTx.Onramp(
tx = OnrampTransaction(
txId = "onramp-1",
@ -656,6 +806,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
createdAtMillis = TIMESTAMP,
provider = null,
payoutHash = null,
externalTxUrl = externalTxUrl,
fromFiat = Amount(
currencySymbol = "SEK",
value = BigDecimal("100"),
@ -666,7 +817,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
networkId = "bitcoin",
amount = BigDecimal("0.006"),
decimals = 8,
cryptoCurrency = currency,
cryptoCurrency = bitcoin,
),
),
txInfo = txInfo,
@ -692,5 +843,6 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
const val TIMESTAMP = 1_700_000_000_000L
const val USER_ADDRESS = "0x1234567890abcdef1234"
const val VALIDATOR_ADDRESS = "0xvalidator"
const val EXTERNAL_URL = "https://provider.example/tx/swap-1"
}
}