Updated on 2026-08-14
This commit is contained in:
parent
0c1c03c266
commit
d9092549aa
13 changed files with 413 additions and 28 deletions
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
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<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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue