Updated on 2026-08-14
This commit is contained in:
parent
416dd75fde
commit
6ca26f33d1
15 changed files with 795 additions and 138 deletions
|
|
@ -44,6 +44,7 @@ dependencies {
|
|||
implementation(projects.domain.balanceHiding)
|
||||
implementation(projects.domain.balanceHiding.models)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.onramp.models)
|
||||
|
||||
/* AndroidX */
|
||||
implementation(deps.androidx.activity.compose)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
|||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction as RowDirection
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle.Direction as SubtitleDirection
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
|
@ -12,6 +13,8 @@ import com.tangem.core.ui.extensions.wrappedList
|
|||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.utils.toTimeFormat
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.ExpressOnrampStatus
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
import com.tangem.domain.txhistory.model.explorerHash
|
||||
|
|
@ -29,7 +32,7 @@ import java.math.BigDecimal
|
|||
* express statuses collapse into the three [Status] buckets (those drive title/icon/amount colors in the row UI).
|
||||
*
|
||||
* The counterparty ticker symbol+icon come from the resolved [ExpressTransactionAsset.cryptoCurrency] (swap);
|
||||
* onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click routes through
|
||||
* onramp shows the fiat code with the onramp country flag as the icon (fiat carries no `CryptoCurrency`). The row click routes through
|
||||
* [TxHistoryUiActions.onTransactionClick] (express rows open the in-app details sheet).
|
||||
*/
|
||||
internal class ExpressTxToTransactionItemUMConverter(
|
||||
|
|
@ -67,16 +70,15 @@ internal class ExpressTxToTransactionItemUMConverter(
|
|||
symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId,
|
||||
icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert),
|
||||
),
|
||||
// TODO: replace null to warning logic.
|
||||
warning = null,
|
||||
warning = swapWarning(swap),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content {
|
||||
val status = onrampStatusConverter.convert(onramp.tx.status)
|
||||
val prefix = when {
|
||||
status is Status.Failed -> ""
|
||||
status is Status.Confirmed -> StringsSigns.PLUS
|
||||
val prefix = when (status) {
|
||||
is Status.Failed -> ""
|
||||
is Status.Confirmed -> StringsSigns.PLUS
|
||||
else -> StringsSigns.TILDE_SIGN
|
||||
}
|
||||
return buildContent(
|
||||
|
|
@ -89,11 +91,12 @@ internal class ExpressTxToTransactionItemUMConverter(
|
|||
subtitle = ContentSubtitle.Asset(
|
||||
direction = SubtitleDirection.FROM,
|
||||
symbol = onramp.tx.fromFiat.currencySymbol,
|
||||
// TODO: fiat carries no OnrampCurrency, so no icon yet — render with a fiat country flag once available.
|
||||
icon = null,
|
||||
icon = CurrencyIconState.FiatIcon(
|
||||
url = onramp.tx.country?.image,
|
||||
fallbackResId = R.drawable.ic_currency_24,
|
||||
),
|
||||
),
|
||||
// TODO: replace null to warning logic.
|
||||
warning = null,
|
||||
warning = onrampWarning(onramp),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -143,4 +146,24 @@ internal class ExpressTxToTransactionItemUMConverter(
|
|||
wrappedList(resourceReference(R.string.tx_history_onramp_top_up)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* KYC-verification warning. Other "problem" statuses (failed / refunded / expired) already surface as the red
|
||||
* [Status.Failed] row title, so they need no extra warning line; only [ExpressExchangeStatus.Verifying] —
|
||||
* which buckets into the in-progress [Status.Unconfirmed] — requires it to signal the pending user action.
|
||||
*/
|
||||
private fun swapWarning(swap: ExpressTx.Swap): TextReference? =
|
||||
if (swap.tx.status == ExpressExchangeStatus.Verifying) {
|
||||
resourceReference(R.string.express_exchange_notification_verification_title)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/** KYC-verification warning; see [swapWarning] for why failed statuses are intentionally excluded. */
|
||||
private fun onrampWarning(onramp: ExpressTx.Onramp): TextReference? =
|
||||
if (onramp.tx.status == ExpressOnrampStatus.Verifying) {
|
||||
resourceReference(R.string.express_exchange_notification_verification_title)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,18 @@ import com.tangem.core.ui.extensions.resourceReference
|
|||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.utils.DateTimeFormatters
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.ExpressOnrampStatus
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.network.TxInfo.TransactionType
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.tokens.model.AmountType
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
import com.tangem.domain.txhistory.model.OnChainTx
|
||||
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
||||
|
|
@ -23,20 +30,25 @@ import com.tangem.utils.StringsSigns
|
|||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.isZero
|
||||
import com.tangem.utils.toBriefAddressFormat
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.joda.time.DateTime
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card.
|
||||
*
|
||||
* The dispatch mirrors the row converters: an [OnChainTx.BSDK] always renders as [TxHistoryDetailsUM.SingleAsset]
|
||||
* (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) currently
|
||||
* produces a header-only [TxHistoryDetailsUM.TwoAssets] with the express status banner. The express legs (`from`/`to`
|
||||
* amounts, currencies, fiat) are populated in a follow-up ([REDACTED_TASK_KEY]).
|
||||
* (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) renders as
|
||||
* [TxHistoryDetailsUM.TwoAssets] — the `from`/`to` legs come from the express deal ([ExchangeTransaction] asset pair /
|
||||
* [OnrampTransaction] fiat→asset), and the network-fee row comes from the matched on-chain leg ([ExpressTx.txInfo]).
|
||||
*/
|
||||
internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
|
||||
private val currency: CryptoCurrency,
|
||||
private val onCopyAddress: (String) -> Unit,
|
||||
/** Own deposit addresses for this currency's network — used to label own-transfers as "Transfer". */
|
||||
private val ownAddresses: Set<String> = emptySet(),
|
||||
) : Converter<TxHistoryInfo, TxHistoryDetailsUM> {
|
||||
|
||||
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
|
||||
|
|
@ -60,8 +72,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
|
|||
header = value.toHeaderUM(),
|
||||
amountBlock = value.toAmountBlockUM(),
|
||||
counterparty = value.toCounterpartyUM(),
|
||||
// TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo.
|
||||
rows = persistentListOf(),
|
||||
// Network fee from the tx itself; rate is not surfaced (no data).
|
||||
rows = value.toInfoRows(),
|
||||
)
|
||||
|
||||
private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM(
|
||||
|
|
@ -74,9 +86,6 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
|
|||
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
|
||||
currencyIcon = iconStateConverter.convert(currency),
|
||||
amount = stringReference(signedAmount(currency)),
|
||||
// TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded
|
||||
// placeholder would show a misleading value.
|
||||
fiatAmount = TextReference.EMPTY,
|
||||
isFailed = status is TxInfo.TransactionStatus.Failed,
|
||||
)
|
||||
|
||||
|
|
@ -102,10 +111,34 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
|
|||
private fun TxInfo.counterpartyLabel(): TextReference =
|
||||
if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from)
|
||||
|
||||
private fun TxInfo.headerTitle(): TextReference = when (type) {
|
||||
is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped)
|
||||
is TransactionType.Transfer -> transferTitle()
|
||||
else -> stringReference(type.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer header label, mirroring the history row: a transfer between the user's own accounts/wallets reads
|
||||
* "Transfer", an outgoing transfer to an external address "Send", an incoming one "Receive" (status-aware).
|
||||
*/
|
||||
private fun TxInfo.transferTitle(): TextReference {
|
||||
val counterpartyAddress = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address
|
||||
val isOwnTransfer = counterpartyAddress != null && counterpartyAddress in ownAddresses
|
||||
return when {
|
||||
isOwnTransfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
|
||||
isOutgoing -> statusAwareTitle(R.string.common_sending, R.string.common_sent)
|
||||
else -> statusAwareTitle(R.string.common_receiving, R.string.common_received)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Express (swap / onramp)
|
||||
|
||||
/**
|
||||
* The two-asset block always renders the deal's `fromAsset`→`toAsset` regardless of [ExpressTx.Swap.isOutgoing] —
|
||||
* `isOutgoing` only selects which leg is the *viewed* one in the history row, it does not reorder the detail legs.
|
||||
*/
|
||||
private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets {
|
||||
val status = exchangeStatusConverter.convert(swap.tx.status)
|
||||
return TxHistoryDetailsUM.TwoAssets(
|
||||
|
|
@ -115,7 +148,18 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
|
|||
title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
|
||||
subtitle = headerSubtitle(swap.timestampMillis),
|
||||
),
|
||||
statusBanner = status.toStatusBannerUM(),
|
||||
from = swap.tx.fromAsset.toAssetUM(
|
||||
label = resourceReference(R.string.swapping_from_title_v2),
|
||||
sign = status.outgoingSign(),
|
||||
isFaded = status is Status.Failed,
|
||||
),
|
||||
to = swap.tx.toAsset.toAssetUM(
|
||||
label = resourceReference(R.string.swapping_to_title),
|
||||
sign = status.incomingSign(),
|
||||
isFaded = status is Status.Failed,
|
||||
),
|
||||
statusBanner = swap.tx.status.toStatusBannerUM(),
|
||||
rows = swap.toInfoRows(),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +175,59 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
|
|||
),
|
||||
subtitle = headerSubtitle(onramp.timestampMillis),
|
||||
),
|
||||
statusBanner = status.toStatusBannerUM(),
|
||||
from = onramp.tx.fromFiat.toFiatAssetUM(
|
||||
label = resourceReference(R.string.swapping_from_title_v2),
|
||||
isFaded = status is Status.Failed,
|
||||
),
|
||||
to = onramp.tx.toAsset.toAssetUM(
|
||||
label = resourceReference(R.string.swapping_to_title),
|
||||
sign = status.incomingSign(),
|
||||
isFaded = status is Status.Failed,
|
||||
),
|
||||
statusBanner = onramp.tx.status.toStatusBannerUM(),
|
||||
rows = onramp.toInfoRows(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one crypto leg of the two-asset block. The ticker symbol and icon come from the resolved
|
||||
* [ExpressTransactionAsset.cryptoCurrency]; when it is unresolved the symbol falls back to the network id and the
|
||||
* icon slot is left empty ([currencyIcon] = `null`).
|
||||
*/
|
||||
private fun ExpressTransactionAsset.toAssetUM(
|
||||
label: TextReference,
|
||||
sign: String,
|
||||
isFaded: Boolean,
|
||||
): TxHistoryDetailsUM.AssetUM {
|
||||
val symbol = cryptoCurrency?.symbol ?: id.networkId
|
||||
val formatted = amount.format { crypto(
|
||||
symbol = symbol,
|
||||
decimals = decimals,
|
||||
ignoreSymbolPosition = true,
|
||||
) }.trim()
|
||||
return TxHistoryDetailsUM.AssetUM(
|
||||
label = label,
|
||||
owner = null,
|
||||
amount = stringReference((sign + formatted).trim()),
|
||||
currencyIcon = cryptoCurrency?.let(iconStateConverter::convert),
|
||||
isFaded = isFaded,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the fiat ("You paid") leg of an onramp. The paid fiat amount is exact and carries no sign — neither `+`/`−`
|
||||
* nor the `~` estimate — so only the value is shown. Fiat has no `CryptoCurrency`, so it also has no icon.
|
||||
*/
|
||||
private fun Amount.toFiatAssetUM(label: TextReference, isFaded: Boolean): TxHistoryDetailsUM.AssetUM {
|
||||
val code = (type as? AmountType.FiatType)?.code ?: currencySymbol
|
||||
val formatted = (value ?: BigDecimal.ZERO)
|
||||
.format { fiat(fiatCurrencyCode = code, fiatCurrencySymbol = currencySymbol) }
|
||||
return TxHistoryDetailsUM.AssetUM(
|
||||
label = label,
|
||||
owner = null,
|
||||
amount = stringReference(formatted.trim()),
|
||||
currencyIcon = null,
|
||||
isFaded = isFaded,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -141,30 +237,94 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
|
|||
// region Status helpers
|
||||
|
||||
/**
|
||||
* Express status plaque under the two-asset block, keyed on the collapsed UI [Status] bucket.
|
||||
* Express swap status → the status plaque under the two-asset block.
|
||||
*
|
||||
* A stopgap shared by on-chain swaps and express ops — [Severity.Warning] (verification) is not reachable here yet.
|
||||
* [REDACTED_TODO_COMMENT]
|
||||
* In-flight stages render as [Severity.Info] with the rotating loader; [Verifying][ExpressExchangeStatus.Verifying]
|
||||
* (KYC) and the paused / refunded terminals as [Severity.Warning]; the failure terminals as [Severity.Error]; the
|
||||
* [Finished][ExpressExchangeStatus.Finished] success as [Severity.Success] (the plaque then auto-collapses — see
|
||||
* `TxHistoryDetailsStatusBanner`). [Unknown][ExpressExchangeStatus.Unknown] carries nothing to show, so it hides the
|
||||
* plaque (`null`).
|
||||
*/
|
||||
private fun Status.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (this) {
|
||||
is Status.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Info,
|
||||
title = resourceReference(R.string.express_exchange_status_receiving_active),
|
||||
isLoading = true,
|
||||
)
|
||||
is Status.Confirmed -> TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Success,
|
||||
title = resourceReference(R.string.express_exchange_status_exchanged),
|
||||
isLoading = false,
|
||||
)
|
||||
is Status.Failed -> TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Error,
|
||||
title = resourceReference(R.string.express_exchange_status_failed),
|
||||
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
|
||||
isLoading = false,
|
||||
)
|
||||
private fun ExpressExchangeStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) {
|
||||
ExpressExchangeStatus.Preview,
|
||||
ExpressExchangeStatus.Created,
|
||||
ExpressExchangeStatus.ExchangeTxSent,
|
||||
ExpressExchangeStatus.Waiting,
|
||||
-> loadingBanner(R.string.express_exchange_status_receiving_active)
|
||||
ExpressExchangeStatus.WaitingTxHash -> loadingBanner(R.string.express_exchange_status_waiting_tx_hash)
|
||||
ExpressExchangeStatus.Confirming -> loadingBanner(R.string.express_exchange_status_confirming_active)
|
||||
ExpressExchangeStatus.Exchanging -> loadingBanner(R.string.express_exchange_status_exchanging_active)
|
||||
ExpressExchangeStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active)
|
||||
ExpressExchangeStatus.Verifying -> verificationBanner()
|
||||
ExpressExchangeStatus.Refunded -> warningBanner(R.string.express_exchange_status_refunded)
|
||||
ExpressExchangeStatus.Paused -> warningBanner(R.string.express_exchange_status_paused)
|
||||
ExpressExchangeStatus.Failed,
|
||||
ExpressExchangeStatus.TxFailed,
|
||||
-> failedBanner()
|
||||
ExpressExchangeStatus.Expired -> errorBanner(R.string.express_exchange_status_failed)
|
||||
ExpressExchangeStatus.Finished -> successBanner(R.string.express_exchange_status_exchanged)
|
||||
ExpressExchangeStatus.Unknown -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Express onramp status → the status plaque under the two-asset block. Same severity mapping as the swap variant; the
|
||||
* [Finished][ExpressOnrampStatus.Finished] success ("Purchase completed") is the only [Severity.Success] (auto-collapsed).
|
||||
*/
|
||||
private fun ExpressOnrampStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) {
|
||||
ExpressOnrampStatus.Created,
|
||||
ExpressOnrampStatus.WaitingForPayment,
|
||||
-> loadingBanner(R.string.express_exchange_status_receiving_active)
|
||||
ExpressOnrampStatus.PaymentProcessing -> loadingBanner(R.string.express_exchange_status_confirming_active)
|
||||
ExpressOnrampStatus.Verifying -> verificationBanner()
|
||||
ExpressOnrampStatus.Paid -> loadingBanner(R.string.express_exchange_status_buying_active)
|
||||
ExpressOnrampStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active)
|
||||
ExpressOnrampStatus.Paused -> warningBanner(R.string.express_exchange_status_paused)
|
||||
ExpressOnrampStatus.Failed -> failedBanner()
|
||||
ExpressOnrampStatus.Expired -> errorBanner(R.string.express_exchange_status_failed)
|
||||
ExpressOnrampStatus.Finished -> successBanner(R.string.express_exchange_status_bought)
|
||||
ExpressOnrampStatus.Unknown -> null
|
||||
}
|
||||
|
||||
private fun loadingBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Info,
|
||||
title = resourceReference(title),
|
||||
isLoading = true,
|
||||
)
|
||||
|
||||
private fun successBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Success,
|
||||
title = resourceReference(title),
|
||||
isLoading = false,
|
||||
)
|
||||
|
||||
private fun warningBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Warning,
|
||||
title = resourceReference(title),
|
||||
isLoading = false,
|
||||
)
|
||||
|
||||
private fun errorBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Error,
|
||||
title = resourceReference(title),
|
||||
isLoading = false,
|
||||
)
|
||||
|
||||
/** Failure terminal: red plaque with the shared "visit provider to refund" hint. */
|
||||
private fun failedBanner() = TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Error,
|
||||
title = resourceReference(R.string.express_exchange_status_failed),
|
||||
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
|
||||
isLoading = false,
|
||||
)
|
||||
|
||||
/** KYC verification: amber plaque with the "visit provider for verification" hint. */
|
||||
private fun verificationBanner() = TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = Severity.Warning,
|
||||
title = resourceReference(R.string.express_exchange_status_verifying),
|
||||
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
|
||||
isLoading = false,
|
||||
)
|
||||
|
||||
private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (this) {
|
||||
is Status.Failed -> resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending)))
|
||||
is Status.Unconfirmed -> resourceReference(pending)
|
||||
|
|
@ -173,8 +333,59 @@ private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirme
|
|||
|
||||
// endregion
|
||||
|
||||
// region Info rows (network fee)
|
||||
|
||||
/** Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). */
|
||||
private fun TxInfo.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = listOfNotNull(feeRow()).toImmutableList()
|
||||
|
||||
/**
|
||||
* Detail rows of an express op: the [provider] row (its name) followed by the network-fee row from the matched on-chain
|
||||
* leg. The provider row is dropped while the provider is unresolved; the fee row while no on-chain leg / fee is present.
|
||||
* (Rate is not surfaced yet — no data.)
|
||||
*/
|
||||
private fun ExpressTx.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> = buildList {
|
||||
provider?.let { add(it.providerRow()) }
|
||||
addAll(txInfo.toInfoRows())
|
||||
}.toImmutableList()
|
||||
|
||||
private fun ExpressProvider.providerRow(): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM(
|
||||
label = resourceReference(R.string.express_provider),
|
||||
value = stringReference(name),
|
||||
trailingIconRes = R.drawable.ic_arrow_top_right_24,
|
||||
)
|
||||
|
||||
/** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */
|
||||
private fun OnChainTx?.toInfoRows(): ImmutableList<TxHistoryDetailsUM.InfoRowUM> =
|
||||
(this as? OnChainTx.BSDK)?.txInfo?.toInfoRows() ?: persistentListOf()
|
||||
|
||||
private fun TxInfo.feeRow(): TxHistoryDetailsUM.InfoRowUM? {
|
||||
val fee = fee ?: return null
|
||||
val value = fee.value ?: return null
|
||||
return TxHistoryDetailsUM.InfoRowUM(
|
||||
label = resourceReference(R.string.common_network_fee_title),
|
||||
value = stringReference(
|
||||
value.format { crypto(symbol = fee.currencySymbol, decimals = fee.decimals, ignoreSymbolPosition = true) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Amount building helpers
|
||||
|
||||
/** Leading sign of the pay-in / "You send" leg: `−` while in flight or settled, dropped on a failed deal. */
|
||||
private fun Status.outgoingSign(): String = if (this is Status.Failed) "" else "${StringsSigns.MINUS} "
|
||||
|
||||
/**
|
||||
* Leading sign of the payout / "You receive" leg: `~` while in flight (the final received amount is still an estimate),
|
||||
* `+` once the funds have settled, and dropped on a failed deal (the amount is then only struck through).
|
||||
*/
|
||||
private fun Status.incomingSign(): String = when (this) {
|
||||
is Status.Unconfirmed -> "${StringsSigns.TILDE_SIGN} "
|
||||
is Status.Confirmed -> "${StringsSigns.PLUS} "
|
||||
is Status.Failed -> ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+`
|
||||
* otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) — the UI then only
|
||||
|
|
@ -201,12 +412,6 @@ private fun TxInfo.headerIcon(): Int = when (type) {
|
|||
else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
|
||||
}
|
||||
|
||||
private fun TxInfo.headerTitle(): TextReference = when (type) {
|
||||
is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped)
|
||||
is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
|
||||
else -> stringReference(type.toString())
|
||||
}
|
||||
|
||||
private fun headerSubtitle(timestampMillis: Long): TextReference {
|
||||
val dateTime = DateTime(timestampMillis)
|
||||
val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
|||
import com.tangem.core.ui.ds.image.DeviceIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* UI model for the in-app transaction details ("Operation") card.
|
||||
|
|
@ -35,15 +36,18 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
|
|||
/**
|
||||
* Two-asset layout: Swap / Onramp.
|
||||
*
|
||||
* [from] ("You sent") → [to] ("You receive") exchange block. Both are nullable: the converter can't populate the
|
||||
* legs yet (`TxInfo` exposes no swap amounts/currencies/fiat), so the card falls back to a header-only placeholder
|
||||
* until that data lands. [statusBanner] is the express status plaque under the block, `null` until status is known.
|
||||
* [from] ("You send") → [to] ("You receive") exchange block. Both are nullable: when a leg cannot be built (e.g. a
|
||||
* future express variant with no asset data) the card falls back to a header-only placeholder. [statusBanner] is
|
||||
* the express status plaque under the block, `null` until status is known. [rows] carries the provider row (its
|
||||
* name) followed by the network-fee row pulled from the matched on-chain leg (`ExpressTx.txInfo`); each is dropped
|
||||
* when its data is unavailable (rate is not surfaced yet — no data).
|
||||
*/
|
||||
data class TwoAssets(
|
||||
override val header: HeaderUM,
|
||||
val from: AssetUM? = null,
|
||||
val to: AssetUM? = null,
|
||||
val statusBanner: StatusBannerUM? = null,
|
||||
val rows: ImmutableList<InfoRowUM> = persistentListOf(),
|
||||
) : TxHistoryDetailsUM
|
||||
|
||||
/**
|
||||
|
|
@ -67,14 +71,18 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
|
|||
|
||||
/**
|
||||
* One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing
|
||||
* side. [owner] `null` → plain label ("You sent"); non-null → "From"/"To" prefix plus the resolved own account /
|
||||
* wallet decoration. [isFaded] renders the unsettled/failed amount (struck through, recolored to tertiary).
|
||||
* side. [owner] `null` → plain label ("You send"); non-null → "From"/"To" prefix plus the resolved own account /
|
||||
* wallet decoration. [isFaded] renders the failed amount (struck through, recolored to tertiary); an in-flight leg is
|
||||
* not faded — it carries a `~` estimate sign instead.
|
||||
*
|
||||
* [currencyIcon] is `null` when the leg has no icon to show — the onramp fiat side carries no `CryptoCurrency` and
|
||||
* no country flag is rendered (no data); the trailing icon slot is then left empty.
|
||||
*/
|
||||
data class AssetUM(
|
||||
val label: TextReference,
|
||||
val owner: AssetOwnerUM?,
|
||||
val amount: TextReference,
|
||||
val currencyIcon: CurrencyIconState,
|
||||
val currencyIcon: CurrencyIconState?,
|
||||
val isFaded: Boolean,
|
||||
)
|
||||
|
||||
|
|
@ -106,23 +114,30 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
|
|||
* Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto
|
||||
* [amount] and the secondary [fiatAmount].
|
||||
*
|
||||
* [fiatAmount] is `null` while no fiat value is available (`TxInfo` has no fiat field yet) — the fiat line is then
|
||||
* omitted entirely rather than shown as a placeholder.
|
||||
*
|
||||
* [isFailed] drives the failed visual state — the amount is struck through, recolored to tertiary and carries no
|
||||
* `+`/`−` sign (mirrors the status-driven recolor in the shared header).
|
||||
*/
|
||||
data class AmountBlockUM(
|
||||
val currencyIcon: CurrencyIconState,
|
||||
val amount: TextReference,
|
||||
val fiatAmount: TextReference,
|
||||
val fiatAmount: TextReference? = null,
|
||||
val isFailed: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* A single info row of the details card: a [label] on the leading side and its [value] on the trailing side
|
||||
* (e.g. `Network fee` → `0.00056 ETH`, `Rate` → `1 POL ≈ 0.36 USDT`). Rendered by [TxHistoryDetailsInfoRows].
|
||||
*
|
||||
* [trailingIconRes] is an optional glyph drawn after the [value] (e.g. the arrow-up-right link affordance on the
|
||||
* provider row); `null` leaves the trailing slot text-only.
|
||||
*/
|
||||
data class InfoRowUM(
|
||||
val label: TextReference,
|
||||
val value: TextReference,
|
||||
@DrawableRes val trailingIconRes: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import com.tangem.core.decompose.di.ModelScoped
|
|||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
|
||||
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
|
||||
import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter
|
||||
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
|
@ -21,18 +25,27 @@ import javax.inject.Inject
|
|||
internal class TxHistoryDetailsModel @Inject constructor(
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val clipboardManager: ClipboardManager,
|
||||
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
|
||||
paramsContainer: ParamsContainer,
|
||||
) : Model() {
|
||||
|
||||
private val params: TxHistoryDetailsComponent.Params = paramsContainer.require()
|
||||
|
||||
private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter(
|
||||
currency = params.currency,
|
||||
onCopyAddress = ::onCopyAddress,
|
||||
)
|
||||
/** Own deposit addresses for the viewed currency's network — drives the own-vs-external transfer title. */
|
||||
private val ownAddressesFlow: Flow<Set<String>> = multiAccountStatusListSupplier()
|
||||
.map { lists -> buildOwnAccountAddressMap(lists, params.currency.network.id.rawId).keys }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val uiState: StateFlow<TxHistoryDetailsUM?> = params.txHistoryInfo
|
||||
.map(converter::convert)
|
||||
val uiState: StateFlow<TxHistoryDetailsUM?> = combine(
|
||||
params.txHistoryInfo,
|
||||
ownAddressesFlow,
|
||||
) { txInfo, ownAddresses ->
|
||||
TxHistoryInfoToTxHistoryDetailsUMConverter(
|
||||
currency = params.currency,
|
||||
onCopyAddress = ::onCopyAddress,
|
||||
ownAddresses = ownAddresses,
|
||||
).convert(txInfo)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package com.tangem.features.txhistory.model
|
||||
|
||||
import com.tangem.core.ui.ds.image.DeviceIconUM
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.filterCryptoPortfolio
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
|
|
@ -17,4 +20,28 @@ internal data class TxHistoryLookupContext(
|
|||
val walletInfoById: Map<UserWalletId, WalletInfo>,
|
||||
)
|
||||
|
||||
internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM)
|
||||
internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM)
|
||||
|
||||
/**
|
||||
* Flattens every crypto-portfolio account of every wallet into an `address -> account` map for the network identified
|
||||
* by [networkRawId]. Shared by the history list and the details screen to decide whether a transfer counterparty is one
|
||||
* of the user's own accounts/wallets.
|
||||
*/
|
||||
internal fun buildOwnAccountAddressMap(
|
||||
lists: List<AccountStatusList>,
|
||||
networkRawId: Network.RawID,
|
||||
): Map<String, Account.CryptoPortfolio> {
|
||||
val map = mutableMapOf<String, Account.CryptoPortfolio>()
|
||||
lists.forEach { accountList ->
|
||||
accountList.accountStatuses
|
||||
.filterCryptoPortfolio()
|
||||
.forEach { status ->
|
||||
status.flattenCurrencies().forEach { currencyStatus ->
|
||||
if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach
|
||||
val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach
|
||||
map[address] = status.account
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
|
@ -9,16 +9,12 @@ import com.tangem.core.decompose.model.ParamsContainer
|
|||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.core.ui.DesignFeatureToggles
|
||||
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.filterCryptoPortfolio
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
|
|
@ -92,7 +88,10 @@ internal class TxHistoryModel @Inject constructor(
|
|||
)
|
||||
.map { (accountLists, modeEnabled, wallets) ->
|
||||
TxHistoryLookupContext(
|
||||
ownAccountByAddress = buildOwnAccountAddressMap(accountLists),
|
||||
ownAccountByAddress = buildOwnAccountAddressMap(
|
||||
lists = accountLists,
|
||||
networkRawId = params.currency.network.id.rawId,
|
||||
),
|
||||
isAccountsModeEnabled = modeEnabled,
|
||||
walletInfoById = wallets.associate { wallet ->
|
||||
wallet.walletId to WalletInfo(
|
||||
|
|
@ -151,23 +150,6 @@ internal class TxHistoryModel @Inject constructor(
|
|||
subscribeOnCurrencyStatusUpdates()
|
||||
}
|
||||
|
||||
private fun buildOwnAccountAddressMap(lists: List<AccountStatusList>): Map<String, Account.CryptoPortfolio> {
|
||||
val networkRawId = params.currency.network.id.rawId
|
||||
val map = mutableMapOf<String, Account.CryptoPortfolio>()
|
||||
lists.forEach { accountList ->
|
||||
accountList.accountStatuses
|
||||
.filterCryptoPortfolio()
|
||||
.forEach { status: AccountStatus.CryptoPortfolio ->
|
||||
status.flattenCurrencies().forEach { currencyStatus ->
|
||||
if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach
|
||||
val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach
|
||||
map[address] = status.account
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private fun subscribeToUiItemChanges() {
|
||||
txHistoryListManager
|
||||
?.uiItems
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.core.ui.R
|
|||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
|
@ -56,17 +57,19 @@ internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountB
|
|||
textAlign = TextAlign.Center,
|
||||
textDecoration = if (amountBlock.isFailed) TextDecoration.LineThrough else null,
|
||||
)
|
||||
SpacerH(4.dp)
|
||||
Text(
|
||||
text = amountBlock.fiatAmount.resolveReference(),
|
||||
color = if (amountBlock.isFailed) {
|
||||
TangemTheme.colors3.text.tertiary
|
||||
} else {
|
||||
TangemTheme.colors3.text.secondary
|
||||
},
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
amountBlock.fiatAmount?.let { fiatAmount ->
|
||||
SpacerH(4.dp)
|
||||
Text(
|
||||
text = fiatAmount.resolveReference(),
|
||||
color = if (amountBlock.isFailed) {
|
||||
TangemTheme.colors3.text.tertiary
|
||||
} else {
|
||||
TangemTheme.colors3.text.secondary
|
||||
},
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -82,20 +85,23 @@ private fun TxHistoryDetailsAmountBlockPreview() {
|
|||
) {
|
||||
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false))
|
||||
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = true))
|
||||
// No fiat — the fiat line is omitted entirely.
|
||||
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false, fiatAmount = null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun previewAmountBlock(isFailed: Boolean) = TxHistoryDetailsUM.AmountBlockUM(
|
||||
currencyIcon = CurrencyIconState.CoinIcon(
|
||||
url = null,
|
||||
fallbackResId = R.drawable.img_eth_22,
|
||||
isGrayscale = false,
|
||||
shouldShowCustomBadge = false,
|
||||
),
|
||||
amount = stringReference("+ 350.31 USDT"),
|
||||
fiatAmount = stringReference("$350.31"),
|
||||
isFailed = isFailed,
|
||||
)
|
||||
private fun previewAmountBlock(isFailed: Boolean, fiatAmount: TextReference? = stringReference("$350.31")) =
|
||||
TxHistoryDetailsUM.AmountBlockUM(
|
||||
currencyIcon = CurrencyIconState.CoinIcon(
|
||||
url = null,
|
||||
fallbackResId = R.drawable.img_eth_22,
|
||||
isGrayscale = false,
|
||||
shouldShowCustomBadge = false,
|
||||
),
|
||||
amount = stringReference("+ 350.31 USDT"),
|
||||
fiatAmount = fiatAmount,
|
||||
isFailed = isFailed,
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
|
@ -58,8 +58,7 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi
|
|||
.padding(start = 16.dp, end = 16.dp),
|
||||
)
|
||||
} else {
|
||||
// TODO([REDACTED_TASK_KEY]): the converter cannot populate the swap legs yet (TxInfo exposes no two-leg / fiat /
|
||||
// provider data). Until those fields land, fall back to the header-only placeholder.
|
||||
// Safety fallback for a future express variant that yields no asset legs — render the header-only card.
|
||||
TwoAssetsPlaceholder(state = state)
|
||||
}
|
||||
// Express status plaque under the exchange block. The top gap is owned by the banner (inside its collapsing
|
||||
|
|
@ -70,6 +69,13 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi
|
|||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
// Network fee (and later rate) pulled from the matched on-chain leg; the block is skipped when [rows] is empty.
|
||||
TxHistoryDetailsInfoRows(
|
||||
rows = state.rows,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,17 @@ package com.tangem.features.txhistory.ui
|
|||
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -20,6 +26,7 @@ import com.tangem.core.ui.extensions.stringReference
|
|||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM
|
||||
import com.tangem.features.txhistory.impl.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
|
@ -50,14 +57,27 @@ internal fun TxHistoryDetailsInfoRows(rows: ImmutableList<InfoRowUM>, modifier:
|
|||
contentLead = TangemRowContentLead.Start,
|
||||
titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) },
|
||||
valueSlot = {
|
||||
Text(
|
||||
text = row.value.resolveReference(),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = row.value.resolveReference(),
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.trailingIconRes?.let { iconRes ->
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors3.text.secondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -77,7 +97,11 @@ private fun TxHistoryDetailsInfoRowsPreview() {
|
|||
// Multiple rows — dividers between rows, none after the last
|
||||
TxHistoryDetailsInfoRows(
|
||||
rows = persistentListOf(
|
||||
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
|
||||
InfoRowUM(
|
||||
label = stringReference("Provider"),
|
||||
value = stringReference("Mercuryo"),
|
||||
trailingIconRes = R.drawable.ic_arrow_top_right_24,
|
||||
),
|
||||
InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")),
|
||||
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -51,6 +52,7 @@ import com.tangem.core.ui.res.generated.icons.ic_success_20
|
|||
import com.tangem.core.ui.res.generated.icons.ic_warning_20
|
||||
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// Animation timings in ms (ProtoPie spec). The status swap is two-phase: the old status fades out, then the new one
|
||||
// fades/slides in after ENTER_DELAY. Most steps run over the default duration; the trailing loader/glyph fades faster
|
||||
|
|
@ -61,6 +63,9 @@ private const val GROW_MILLIS = 400
|
|||
private const val ENTER_DELAY_MILLIS = DEFAULT_ANIMATION_MILLIS // phase 2 waits for the phase-1 fade-out to clear
|
||||
private const val SUBTITLE_DELAY_MILLIS = ENTER_DELAY_MILLIS + 100 // subtitle trails the title
|
||||
|
||||
/** How long the success terminal ("Confirmed") lingers before the plaque auto-collapses — it shows only as a transition. */
|
||||
private const val CONFIRMED_VISIBLE_MILLIS = 1_000L
|
||||
|
||||
private const val TITLE_SLIDE_FRACTION = 12 // in-progress/Success title slides in 1/12 width from the right
|
||||
private const val CONTENT_RISE_FRACTION = 2 // Warning/Error title floats up 1/2 height from below
|
||||
private const val ICON_ENTER_SCALE = 0.6f
|
||||
|
|
@ -134,8 +139,31 @@ internal fun TxHistoryDetailsStatusBanner(state: StatusBannerUM?, modifier: Modi
|
|||
SideEffect { if (state != null) lastState.value = state }
|
||||
val content = state ?: lastState.value
|
||||
|
||||
// Auto-hide rules for the success terminal ("Confirmed"). It is the only [Severity.Success] state and must read as a
|
||||
// *transition*, not a resting state: opening the details on an already-finished deal (no in-flight status was ever
|
||||
// seen) shows nothing, and once it does appear it lingers only briefly before collapsing. Failure / verification
|
||||
// terminals are not Success, so they stay put.
|
||||
val seenNonSuccess = remember { mutableStateOf(false) }
|
||||
SideEffect { if (state != null && state.severity != Severity.Success) seenNonSuccess.value = true }
|
||||
|
||||
val isTerminalSuccess = state?.severity == Severity.Success
|
||||
val confirmedDismissed = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isTerminalSuccess) {
|
||||
if (isTerminalSuccess && seenNonSuccess.value) {
|
||||
delay(CONFIRMED_VISIBLE_MILLIS)
|
||||
confirmedDismissed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
val isVisible = when {
|
||||
state == null -> false
|
||||
isTerminalSuccess && !seenNonSuccess.value -> false // opened already on the success terminal → never shown
|
||||
isTerminalSuccess && confirmedDismissed.value -> false // "Confirmed" lingered long enough → collapse away
|
||||
else -> true
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state != null,
|
||||
visible = isVisible,
|
||||
// Fade and size share one tween so alpha and height finish together (mismatched default springs leave a jerk).
|
||||
enter = fadeIn(tween(DEFAULT_ANIMATION_MILLIS)) +
|
||||
expandVertically(tween(DEFAULT_ANIMATION_MILLIS), expandFrom = Alignment.Top),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
|
|
@ -58,7 +57,7 @@ internal fun TxHistoryDetailsTopNavigation(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TangemTopNavigation(
|
||||
modifier = modifier.padding(top = 8.dp),
|
||||
modifier = modifier,
|
||||
windowInsets = WindowInsets(0),
|
||||
blurBackground = false,
|
||||
startButton = { StatusActionIcon(iconRes = header.iconRes, status = header.status) },
|
||||
|
|
|
|||
|
|
@ -112,10 +112,13 @@ private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) {
|
|||
)
|
||||
},
|
||||
endSlot = {
|
||||
TangemCurrencyIcon(
|
||||
state = asset.currencyIcon,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
// The fiat leg of an onramp carries no icon (no CryptoCurrency, no country flag) — leave the slot empty.
|
||||
asset.currencyIcon?.let { icon ->
|
||||
TangemCurrencyIcon(
|
||||
state = icon,
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -231,10 +234,11 @@ private fun TxHistoryDetailsTwoAssetsBlockPreview() {
|
|||
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
|
||||
to = previewAsset(label = "You receive", amount = "+ 1,800.00 POL", isFaded = false),
|
||||
)
|
||||
// Unsettled swap — the "You receive" side is struck through until the funds arrive.
|
||||
// Unsettled swap — the "You receive" side shows the estimated amount with a `~` until the funds arrive
|
||||
// (struck through is reserved for the failed state).
|
||||
TxHistoryDetailsTwoAssetsBlock(
|
||||
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
|
||||
to = previewAsset(label = "You receive", amount = "1,800.00 POL", isFaded = true),
|
||||
to = previewAsset(label = "You receive", amount = "~ 1,800.00 POL", isFaded = false),
|
||||
)
|
||||
// Account -> another account (own-to-own transfer between two of the user's accounts).
|
||||
TxHistoryDetailsTwoAssetsBlock(
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ import com.tangem.domain.express.models.ExchangeTransaction
|
|||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.ExpressOnrampStatus
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressProviderType
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.express.models.OnrampTransaction
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.SdkAmount
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.network.TxInfo.TransactionType
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
|
|
@ -94,9 +98,12 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
)
|
||||
|
||||
@Test
|
||||
fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() {
|
||||
fun `GIVEN incoming confirmed external Transfer WHEN convert THEN header has down icon, confirmed status, received title`() {
|
||||
// Arrange
|
||||
val tx = onChain(type = TransactionType.Transfer)
|
||||
val tx = onChain(
|
||||
type = TransactionType.Transfer,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
// Act
|
||||
val header = converter.convert(tx).header
|
||||
|
|
@ -104,6 +111,64 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
// Assert
|
||||
assertThat(header.iconRes).isEqualTo(R.drawable.ic_arrow_down_24)
|
||||
assertThat(header.status).isEqualTo(TransactionItemUM.Content.Status.Confirmed)
|
||||
assertThat(header.title).isEqualTo(resourceReference(R.string.common_received))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN outgoing external Transfer WHEN convert THEN sent title`() {
|
||||
// Arrange
|
||||
val tx = onChain(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
// Act
|
||||
val header = converter.convert(tx).header
|
||||
|
||||
// Assert
|
||||
assertThat(header.title).isEqualTo(resourceReference(R.string.common_sent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN incoming Transfer from own address WHEN convert THEN transferred title`() {
|
||||
// Arrange — the counterparty is one of the user's own deposit addresses.
|
||||
val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
|
||||
currency = currency,
|
||||
onCopyAddress = copiedAddresses::add,
|
||||
ownAddresses = setOf(USER_ADDRESS),
|
||||
)
|
||||
val tx = onChain(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = false,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
// Act
|
||||
val header = ownConverter.convert(tx).header
|
||||
|
||||
// Assert
|
||||
assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN outgoing Transfer to own address WHEN convert THEN transferred title`() {
|
||||
// Arrange
|
||||
val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter(
|
||||
currency = currency,
|
||||
onCopyAddress = copiedAddresses::add,
|
||||
ownAddresses = setOf(USER_ADDRESS),
|
||||
)
|
||||
val tx = onChain(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
// Act
|
||||
val header = ownConverter.convert(tx).header
|
||||
|
||||
// Assert
|
||||
assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred))
|
||||
}
|
||||
|
||||
|
|
@ -238,6 +303,35 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
assertThat(copiedAddresses).containsExactly(USER_ADDRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN tx with fee WHEN convert THEN single network-fee row`() {
|
||||
// Arrange
|
||||
val tx = onChain(
|
||||
type = TransactionType.Transfer,
|
||||
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
|
||||
)
|
||||
|
||||
// Act
|
||||
val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows
|
||||
|
||||
// Assert
|
||||
assertThat(rows).hasSize(1)
|
||||
assertThat(rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title))
|
||||
assertThat(rows.first().value.resolveString()).contains("ETH")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN tx without fee WHEN convert THEN no rows`() {
|
||||
// Arrange
|
||||
val tx = onChain(type = TransactionType.Transfer, fee = null)
|
||||
|
||||
// Act
|
||||
val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows
|
||||
|
||||
// Assert
|
||||
assertThat(rows).isEmpty()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Express (swap / onramp)
|
||||
|
|
@ -253,7 +347,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN in-progress express swap WHEN convert THEN info status banner with loader`() {
|
||||
fun `GIVEN exchanging express swap WHEN convert THEN info status banner with loader`() {
|
||||
// Act
|
||||
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging))
|
||||
val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner
|
||||
|
|
@ -262,12 +356,54 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
assertThat(banner).isEqualTo(
|
||||
TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info,
|
||||
title = resourceReference(R.string.express_exchange_status_receiving_active),
|
||||
title = resourceReference(R.string.express_exchange_status_exchanging_active),
|
||||
isLoading = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verifying express swap WHEN convert THEN warning status banner with verification subtitle`() {
|
||||
// Act
|
||||
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Verifying))
|
||||
val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner
|
||||
|
||||
// Assert
|
||||
assertThat(banner).isEqualTo(
|
||||
TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning,
|
||||
title = resourceReference(R.string.express_exchange_status_verifying),
|
||||
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN finished express swap WHEN convert THEN success status banner`() {
|
||||
// Act
|
||||
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished))
|
||||
val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner
|
||||
|
||||
// Assert
|
||||
assertThat(banner).isEqualTo(
|
||||
TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
|
||||
title = resourceReference(R.string.express_exchange_status_exchanged),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown express swap WHEN convert THEN no status banner`() {
|
||||
// Act
|
||||
val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Unknown))
|
||||
|
||||
// Assert — nothing to surface, the plaque is hidden.
|
||||
assertThat((swap as TxHistoryDetailsUM.TwoAssets).statusBanner).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() {
|
||||
// Act
|
||||
|
|
@ -285,6 +421,131 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN in-progress express swap WHEN convert THEN from is minus and to is approx, neither faded`() {
|
||||
// Act
|
||||
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
assertThat(result.from?.amount?.resolveString()).startsWith("- ")
|
||||
assertThat(result.from?.isFaded).isFalse()
|
||||
// Receive amount is still an estimate while in flight: `~`, not `+`, and not struck through.
|
||||
assertThat(result.to?.amount?.resolveString()).startsWith("~ ")
|
||||
assertThat(result.to?.isFaded).isFalse()
|
||||
// Counterparty (to) symbol comes from the resolved CryptoCurrency; the unresolved from leg falls back to network id.
|
||||
assertThat(result.to?.currencyIcon).isNotNull()
|
||||
assertThat(result.from?.currencyIcon).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN finished express swap WHEN convert THEN to is plus and neither leg faded`() {
|
||||
// Act
|
||||
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished)) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
assertThat(result.from?.amount?.resolveString()).startsWith("- ")
|
||||
assertThat(result.to?.amount?.resolveString()).startsWith("+ ")
|
||||
assertThat(result.from?.isFaded).isFalse()
|
||||
assertThat(result.to?.isFaded).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN failed express swap WHEN convert THEN both legs faded and signs dropped`() {
|
||||
// Act
|
||||
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
assertThat(result.from?.isFaded).isTrue()
|
||||
assertThat(result.to?.isFaded).isTrue()
|
||||
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
|
||||
assertThat(result.to?.amount?.resolveString()).doesNotContain("+")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN express swap with matched on-chain leg WHEN convert THEN network-fee row from leg`() {
|
||||
// Arrange
|
||||
val leg = onChain(
|
||||
type = TransactionType.Swap,
|
||||
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg)) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
assertThat(result.rows).hasSize(1)
|
||||
assertThat(result.rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN express swap with provider WHEN convert THEN provider row with its name and link icon`() {
|
||||
// Act
|
||||
val result = converter.convert(
|
||||
expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Mercuryo")),
|
||||
) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
assertThat(result.rows).hasSize(1)
|
||||
val providerRow = result.rows.first()
|
||||
assertThat(providerRow.label).isEqualTo(resourceReference(R.string.express_provider))
|
||||
assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo")
|
||||
assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN express swap with provider and on-chain leg WHEN convert THEN provider row precedes network-fee row`() {
|
||||
// Arrange
|
||||
val leg = onChain(
|
||||
type = TransactionType.Swap,
|
||||
fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = converter.convert(
|
||||
expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg, provider = provider(name = "Changelly")),
|
||||
) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
assertThat(result.rows.map { it.label }).containsExactly(
|
||||
resourceReference(R.string.express_provider),
|
||||
resourceReference(R.string.common_network_fee_title),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN finished express onramp WHEN convert THEN paid fiat is unsigned and topped-up crypto is plus`() {
|
||||
// Act
|
||||
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
// "You paid" fiat carries no icon and no sign — the exact amount paid.
|
||||
assertThat(result.from?.currencyIcon).isNull()
|
||||
assertThat(result.from?.amount?.resolveString()).contains("SEK")
|
||||
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
|
||||
assertThat(result.from?.amount?.resolveString()).doesNotContain("+")
|
||||
assertThat(result.from?.amount?.resolveString()).doesNotContain("~")
|
||||
// Topped-up crypto leg is settled: `+`, with an icon.
|
||||
assertThat(result.to?.currencyIcon).isNotNull()
|
||||
assertThat(result.to?.amount?.resolveString()).startsWith("+ ")
|
||||
assertThat(result.to?.isFaded).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN in-progress express onramp WHEN convert THEN paid fiat is unsigned and top-up crypto is approx`() {
|
||||
// Act
|
||||
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Sending)) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
// "You paid" stays unsigned regardless of status.
|
||||
assertThat(result.from?.amount?.resolveString()).doesNotContain("-")
|
||||
assertThat(result.from?.amount?.resolveString()).doesNotContain("+")
|
||||
assertThat(result.from?.amount?.resolveString()).doesNotContain("~")
|
||||
assertThat(result.from?.isFaded).isFalse()
|
||||
// Crypto to-be-received is an estimate while in flight: `~`, not struck through.
|
||||
assertThat(result.to?.amount?.resolveString()).startsWith("~ ")
|
||||
assertThat(result.to?.isFaded).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN finished express onramp WHEN convert THEN TwoAssets with success banner`() {
|
||||
// Act
|
||||
|
|
@ -295,12 +556,37 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
assertThat(result.statusBanner).isEqualTo(
|
||||
TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
|
||||
title = resourceReference(R.string.express_exchange_status_exchanged),
|
||||
title = resourceReference(R.string.express_exchange_status_bought),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN verifying express onramp WHEN convert THEN warning status banner with verification subtitle`() {
|
||||
// Act
|
||||
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Verifying)) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert
|
||||
assertThat(result.statusBanner).isEqualTo(
|
||||
TxHistoryDetailsUM.StatusBannerUM(
|
||||
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning,
|
||||
title = resourceReference(R.string.express_exchange_status_verifying),
|
||||
subtitle = resourceReference(R.string.express_exchange_notification_verification_text),
|
||||
isLoading = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unknown express onramp WHEN convert THEN no status banner`() {
|
||||
// Act
|
||||
val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Unknown)) as TxHistoryDetailsUM.TwoAssets
|
||||
|
||||
// Assert — nothing to surface, the plaque is hidden.
|
||||
assertThat(result.statusBanner).isNull()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun onChain(
|
||||
|
|
@ -309,6 +595,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed,
|
||||
amount: BigDecimal = BigDecimal.ONE,
|
||||
interactionAddressType: TxInfo.InteractionAddressType? = null,
|
||||
fee: SdkAmount? = null,
|
||||
): OnChainTx.BSDK = OnChainTx.BSDK(
|
||||
TxInfo(
|
||||
txHash = TX_HASH,
|
||||
|
|
@ -320,25 +607,49 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
status = status,
|
||||
type = type,
|
||||
amount = amount,
|
||||
fee = fee,
|
||||
),
|
||||
)
|
||||
|
||||
private fun expressSwap(status: ExpressExchangeStatus): ExpressTx.Swap = ExpressTx.Swap(
|
||||
private fun provider(name: String): ExpressProvider = ExpressProvider(
|
||||
providerId = "provider-1",
|
||||
name = name,
|
||||
type = ExpressProviderType.CEX,
|
||||
imageLarge = "",
|
||||
termsOfUse = null,
|
||||
privacyPolicy = null,
|
||||
slippage = null,
|
||||
)
|
||||
|
||||
private fun expressSwap(
|
||||
status: ExpressExchangeStatus,
|
||||
isOutgoing: Boolean = true,
|
||||
txInfo: OnChainTx? = null,
|
||||
provider: ExpressProvider? = null,
|
||||
): ExpressTx.Swap = ExpressTx.Swap(
|
||||
tx = ExchangeTransaction(
|
||||
txId = "swap-1",
|
||||
status = status,
|
||||
createdAtMillis = TIMESTAMP,
|
||||
provider = null,
|
||||
provider = provider,
|
||||
payinHash = null,
|
||||
payoutHash = null,
|
||||
fromAsset = expressAsset(networkId = "ethereum", amount = BigDecimal("1.5"), decimals = 18),
|
||||
toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.001"), decimals = 8),
|
||||
toAsset = expressAsset(
|
||||
networkId = "bitcoin",
|
||||
amount = BigDecimal("0.001"),
|
||||
decimals = 8,
|
||||
cryptoCurrency = currency,
|
||||
),
|
||||
),
|
||||
isOutgoing = true,
|
||||
txInfo = null,
|
||||
isOutgoing = isOutgoing,
|
||||
txInfo = txInfo,
|
||||
)
|
||||
|
||||
private fun expressOnramp(status: ExpressOnrampStatus): ExpressTx.Onramp = ExpressTx.Onramp(
|
||||
private fun expressOnramp(
|
||||
status: ExpressOnrampStatus,
|
||||
txInfo: OnChainTx? = null,
|
||||
): ExpressTx.Onramp = ExpressTx.Onramp(
|
||||
tx = OnrampTransaction(
|
||||
txId = "onramp-1",
|
||||
status = status,
|
||||
|
|
@ -351,16 +662,27 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest {
|
|||
decimals = 2,
|
||||
type = AmountType.FiatType(code = "SEK"),
|
||||
),
|
||||
toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.006"), decimals = 8),
|
||||
toAsset = expressAsset(
|
||||
networkId = "bitcoin",
|
||||
amount = BigDecimal("0.006"),
|
||||
decimals = 8,
|
||||
cryptoCurrency = currency,
|
||||
),
|
||||
),
|
||||
txInfo = null,
|
||||
txInfo = txInfo,
|
||||
)
|
||||
|
||||
private fun expressAsset(networkId: String, amount: BigDecimal, decimals: Int): ExpressTransactionAsset =
|
||||
private fun expressAsset(
|
||||
networkId: String,
|
||||
amount: BigDecimal,
|
||||
decimals: Int,
|
||||
cryptoCurrency: CryptoCurrency? = null,
|
||||
): ExpressTransactionAsset =
|
||||
ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = networkId, contractAddress = "0"),
|
||||
amount = amount,
|
||||
decimals = decimals,
|
||||
cryptoCurrency = cryptoCurrency,
|
||||
)
|
||||
|
||||
private fun TextReference.resolveString(): String = (this as TextReference.Str).value
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue