From d9092549aa176e9efe199c003354312a67dac289 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 14:58:52 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../domain/token/MockCryptoCurrencyFactory.kt | 1 + core/res/src/main/res/values/strings.xml | 1 + .../converter/ExpressTxHistoryConverter.kt | 2 + .../express/models/ExchangeTransaction.kt | 3 + .../express/models/OnrampTransaction.kt | 3 + .../domain/txhistory/model/TxHistoryInfo.kt | 8 + ...istoryInfoToTxHistoryDetailsUMConverter.kt | 143 +++++++++++++-- .../txhistory/entity/TxHistoryDetailsUM.kt | 24 ++- .../txhistory/model/TxHistoryDetailsModel.kt | 3 + .../txhistory/ui/TxHistoryDetailsContent.kt | 16 ++ .../txhistory/ui/TxHistoryDetailsInfoRows.kt | 3 +- ...TxHistoryDetailsModalBottomSheetContent.kt | 62 +++++++ ...ryInfoToTxHistoryDetailsUMConverterTest.kt | 172 +++++++++++++++++- 13 files changed, 413 insertions(+), 28 deletions(-) diff --git a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt index 70dbb5ec33..ca6fa0bed6 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt @@ -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) } diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index a0ebd7f24c..0f66cd5146 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -418,6 +418,7 @@ Privacy Policy %1$s-%2$s %1$s — %2$s + Rate Read more Receive Received diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt index d911aa525b..8d001e036c 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt @@ -66,6 +66,7 @@ internal class ExpressOnrampConverter : Converter 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 = emptySet(), ) : Converter { @@ -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 = 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 = buildList { - provider?.let { add(it.providerRow()) } +private fun ExpressTx.toInfoRows( + onProviderClick: (() -> Unit)?, + rateRow: TxHistoryDetailsUM.InfoRowUM?, +): ImmutableList = buildList { + provider?.let { add(it.providerRow(onProviderClick)) } + rateRow?.let { add(it) } addAll(txInfo.toInfoRows()) }.toImmutableList() -private fun ExpressProvider.providerRow(): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM( - label = resourceReference(R.string.express_provider), - value = stringReference(name), - trailingIconRes = R.drawable.ic_arrow_top_right_24, -) +private fun ExpressProvider.providerRow(onClick: (() -> Unit)?): TxHistoryDetailsUM.InfoRowUM = + TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.express_provider), + value = stringReference(name), + // The arrow link affordance is shown only when the row opens the provider page. + trailingIconRes = onClick?.let { R.drawable.ic_arrow_top_right_24 }, + onClick = onClick, + ) /** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */ private fun OnChainTx?.toInfoRows(): ImmutableList = @@ -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. */ diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index 1a6e454f79..179cd1d3c6 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -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 = 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, ) /** diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt index 52c3732eab..9837280310 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -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) } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt index 14ed97a642..203f473ed4 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt @@ -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, + ) + } } } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt index 3341e7358b..66e5ebf47f 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt @@ -54,7 +54,8 @@ internal fun TxHistoryDetailsInfoRows(rows: ImmutableList, 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( diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt index bf58d7e39c..ce6000cd73 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt @@ -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 \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index 505744835a..e92ab06cf2 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -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() + private val openedUrls = mutableListOf() 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" } } \ No newline at end of file