Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 19:26:18 +03:00
parent 0c4d14650d
commit 2561a7eef6
11 changed files with 389 additions and 4 deletions

View file

@ -63,6 +63,17 @@ sealed interface OnChainTx : TxHistoryInfo {
*/
fun TxInfo.identityKey(): String = "$txHash|$type"
/**
* Hash of the matched on-chain leg, used to open the row in a block explorer; `null` when there is no
* blockchain tx to link to an [ExpressTx] whose on-chain leg has not matched yet. The express `txId`
* must never stand in here: it is not an on-chain hash and would build a broken explorer URL.
*/
inline val TxHistoryInfo.explorerHash: String?
get() = when (this) {
is OnChainTx.BSDK -> txInfo.txHash
is ExpressTx -> matchHash
}
/**
* A history row backed by an express operation. It is a thin wrapper over the standalone express
* model ([ExchangeTransaction] / [OnrampTransaction]), adding only the history-view concerns:

View file

@ -15,6 +15,7 @@ dependencies {
/** Domain models */
api(projects.domain.models)
api(projects.domain.txhistory)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)

View file

@ -0,0 +1,12 @@
package com.tangem.features.txhistory.component
import com.tangem.domain.txhistory.model.TxHistoryInfo
import kotlinx.coroutines.flow.Flow
/**
* Activation config for the transaction-details child slot owned by the host (e.g. token-details).
*
* with `serializer = null` the details sheet is intentionally not restored after process death.
*/
data class TxHistoryDetailsSlotConfig(val txHistoryInfo: Flow<TxHistoryInfo>)

View file

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

View file

@ -0,0 +1,37 @@
package com.tangem.features.txhistory.converter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.utils.converter.Converter
/**
* Collapses the typed swap status into a UI [Status] bucket: the single success state
* ([Finished][ExpressExchangeStatus.Finished]), the failure/return states ([Failed][ExpressExchangeStatus.Failed]/
* [TxFailed][ExpressExchangeStatus.TxFailed]/[Refunded][ExpressExchangeStatus.Refunded]/
* [Expired][ExpressExchangeStatus.Expired]/[Unknown][ExpressExchangeStatus.Unknown]) [Failed][Status.Failed],
* everything in flight (incl. [Verifying][ExpressExchangeStatus.Verifying] and [Paused][ExpressExchangeStatus.Paused])
* [Unconfirmed][Status.Unconfirmed].
*/
internal class ExpressExchangeStatusToUiStatusConverter : Converter<ExpressExchangeStatus, Status> {
override fun convert(value: ExpressExchangeStatus): Status = when (value) {
ExpressExchangeStatus.Finished -> Status.Confirmed
ExpressExchangeStatus.Failed,
ExpressExchangeStatus.TxFailed,
ExpressExchangeStatus.Refunded,
ExpressExchangeStatus.Expired,
ExpressExchangeStatus.Unknown,
-> Status.Failed
ExpressExchangeStatus.Preview,
ExpressExchangeStatus.Created,
ExpressExchangeStatus.ExchangeTxSent,
ExpressExchangeStatus.Waiting,
ExpressExchangeStatus.WaitingTxHash,
ExpressExchangeStatus.Confirming,
ExpressExchangeStatus.Exchanging,
ExpressExchangeStatus.Sending,
ExpressExchangeStatus.Verifying,
ExpressExchangeStatus.Paused,
-> Status.Unconfirmed
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.txhistory.converter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.utils.converter.Converter
/**
* Collapses the typed onramp status into a UI [Status] bucket: the single success state
* ([Finished][ExpressOnrampStatus.Finished]), the failure states ([Failed][ExpressOnrampStatus.Failed]/
* [Expired][ExpressOnrampStatus.Expired]/[Unknown][ExpressOnrampStatus.Unknown]) [Failed][Status.Failed],
* everything in flight [Unconfirmed][Status.Unconfirmed].
*/
internal class ExpressOnrampStatusToUiStatusConverter : Converter<ExpressOnrampStatus, Status> {
override fun convert(value: ExpressOnrampStatus): Status = when (value) {
ExpressOnrampStatus.Finished -> Status.Confirmed
ExpressOnrampStatus.Failed,
ExpressOnrampStatus.Expired,
ExpressOnrampStatus.Unknown,
-> Status.Failed
ExpressOnrampStatus.Created,
ExpressOnrampStatus.WaitingForPayment,
ExpressOnrampStatus.PaymentProcessing,
ExpressOnrampStatus.Verifying,
ExpressOnrampStatus.Paid,
ExpressOnrampStatus.Sending,
ExpressOnrampStatus.Paused,
-> Status.Unconfirmed
}
}

View file

@ -0,0 +1,230 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.extensions.TextReference
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.format
import com.tangem.core.ui.utils.DateTimeFormatters
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.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.OnChainTx
import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import com.tangem.features.txhistory.impl.R
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.toBriefAddressFormat
import kotlinx.collections.immutable.persistentListOf
import org.joda.time.DateTime
/**
* 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]).
*/
internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
private val currency: CryptoCurrency,
private val onCopyAddress: (String) -> Unit,
) : Converter<TxHistoryInfo, TxHistoryDetailsUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter()
private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter()
override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) {
is OnChainTx.BSDK -> convertOnChain(value.txInfo)
is ExpressTx.Swap -> convertExpressSwap(value)
is ExpressTx.Onramp -> convertExpressOnramp(value)
}
// region On-chain (TxInfo)
/**
* Every on-chain row renders as [TxHistoryDetailsUM.SingleAsset]. A two-asset swap always surfaces as
* [ExpressTx.Swap] (handled separately); an on-chain `TxInfo` of type `Swap` (e.g. a DEX swap with no express
* record) carries no legs, so it falls back to the single amount it does have rather than an empty two-asset card.
*/
private fun convertOnChain(value: TxInfo): TxHistoryDetailsUM = TxHistoryDetailsUM.SingleAsset(
header = value.toHeaderUM(),
amountBlock = value.toAmountBlockUM(),
counterparty = value.toCounterpartyUM(),
// TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo.
rows = persistentListOf(),
)
private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM(
iconRes = headerIcon(),
status = status.toUiStatus(),
title = headerTitle(),
subtitle = headerSubtitle(timestampInMillis),
)
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,
)
/**
* Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced built from
* the `User` interaction address (the same source the history list uses for its external-address subtitle).
*
* The own-account / own-wallet avatars require the address->owner lookup the list assembles in
* `TxHistoryLookupContext`; wiring that into the detail model is a follow-up, so for now a counterparty that is not
* a plain external `User` address yields no card (`null`).
*/
private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? {
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
return TxHistoryDetailsUM.CounterpartyUM(
label = counterpartyLabel(),
title = stringReference(address.toBriefAddressFormat()),
avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address),
onCopyClick = { onCopyAddress(address) },
)
}
/** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */
private fun TxInfo.counterpartyLabel(): TextReference =
if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from)
// endregion
// region Express (swap / onramp)
private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets {
val status = exchangeStatusConverter.convert(swap.tx.status)
return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_exchange_vertical_24,
status = status,
title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
subtitle = headerSubtitle(swap.timestampMillis),
),
statusBanner = status.toStatusBannerUM(),
)
}
private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets {
val status = onrampStatusConverter.convert(onramp.tx.status)
return TxHistoryDetailsUM.TwoAssets(
header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_tangem_card_24,
status = status,
title = status.statusAwareTitle(
R.string.tx_history_onramp_top_up,
R.string.tx_history_onramp_topped_up,
),
subtitle = headerSubtitle(onramp.timestampMillis),
),
statusBanner = status.toStatusBannerUM(),
)
}
// endregion
}
// region Status helpers
/**
* Express status plaque under the two-asset block, keyed on the collapsed UI [Status] bucket.
*
* A stopgap shared by on-chain swaps and express ops [Severity.Warning] (verification) is not reachable here yet.
* [REDACTED_TODO_COMMENT]
*/
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 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)
is Status.Confirmed -> resourceReference(confirmed)
}
// endregion
// region Amount building helpers
/**
* 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
* strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed].
*/
private fun TxInfo.signedAmount(currency: CryptoCurrency): String {
val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) }
val prefix = when {
status is TxInfo.TransactionStatus.Failed -> ""
amount.isZero() -> ""
isOutgoing -> "${StringsSigns.MINUS} "
else -> "${StringsSigns.PLUS} "
}
return (prefix + formatted).trim()
}
// endregion
// region Header building helpers
/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */
private fun TxInfo.headerIcon(): Int = when (type) {
is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24
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)
val time = DateTimeFormatters.timeFormatter.print(dateTime)
return stringReference("$date, $time")
}
private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) {
is TxInfo.TransactionStatus.Failed ->
resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending)))
is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending)
is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed)
}
private fun TxInfo.TransactionStatus.toUiStatus(): Status = when (this) {
TxInfo.TransactionStatus.Confirmed -> Status.Confirmed
TxInfo.TransactionStatus.Failed -> Status.Failed
TxInfo.TransactionStatus.Unconfirmed -> Status.Unconfirmed
}
// endregion

View file

@ -89,8 +89,9 @@ internal class TxInfoToTxHistoryDetailsUMConverter(
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),
// TODO: TxInfo has no fiat amount yet — placeholder until the fiat field is added to TxInfo.
fiatAmount = stringReference("\$0.00"),
// 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,
)

View file

@ -14,7 +14,7 @@ import kotlinx.collections.immutable.ImmutableList
* UI model for the in-app transaction details ("Operation") card.
*
* One model for all transaction types; the layout family is chosen from the transaction type by
* `TxInfoToTxHistoryDetailsUMConverter`:
* `TxHistoryInfoToTxHistoryDetailsUMConverter`:
* - [SingleAsset] Receive / Send / Transfer
* - [TwoAssets] Swap / Onramp
*/
@ -131,7 +131,7 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
*
* The layout is identical across counterparty kinds; the only variance is the [avatar] (see [CounterpartyAvatar])
* and whether copy is offered. Only the [CounterpartyAvatar.Address] kind is currently produced by
* [com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet
* [com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet
* avatars are populated in a follow-up, once the detail model assembles the same address->owner lookup the list
* uses (`TxHistoryLookupContext`).
*

View file

@ -52,6 +52,18 @@ internal class HistoryTxListManager @AssistedInject constructor(
val paginationStatus: Flow<PaginationStatus<*>> = state.map { it.status }.distinctUntilChanged()
/**
* Reactive stream of a single row tracked by its [TxHistoryInfo.txId], for the in-app details sheet.
*
* Seeded with the tapped [item] so the sheet always has an immediate snapshot, then re-emits the matching row from
* the live merged list as its status changes. The seed also covers rows not present in [items] yet (e.g. a pending
* tx surfaced from the currency status), which would otherwise never resolve.
*/
fun txHistoryInfoFlow(item: TxHistoryInfo): Flow<TxHistoryInfo> = items
.mapNotNull { list -> list.firstOrNull { it.txId == item.txId } }
.onStart { emit(item) }
.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun init() {
coroutineScope {

View file

@ -0,0 +1,50 @@
package com.tangem.features.txhistory.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressOnrampStatusToUiStatusConverterTest {
private val converter = ExpressOnrampStatusToUiStatusConverter()
@ParameterizedTest
@ProvideTestModels
fun convert(model: Model) {
// Act
val actual = converter.convert(model.status)
// Assert
assertThat(actual).isEqualTo(model.expected)
}
@Test
fun `GIVEN every onramp status WHEN listing test models THEN all enum entries are covered`() {
// Asserts the mapping table below stays in lockstep with the enum, so a newly added status
// can never silently fall through with an untested UI bucket.
val covered = provideTestModels().map { it.status }.toSet()
assertThat(covered).containsExactlyElementsIn(ExpressOnrampStatus.entries)
}
private fun provideTestModels() = listOf(
Model(status = ExpressOnrampStatus.Finished, expected = Status.Confirmed),
Model(status = ExpressOnrampStatus.Failed, expected = Status.Failed),
Model(status = ExpressOnrampStatus.Expired, expected = Status.Failed),
Model(status = ExpressOnrampStatus.Unknown, expected = Status.Failed),
Model(status = ExpressOnrampStatus.Created, expected = Status.Unconfirmed),
Model(status = ExpressOnrampStatus.WaitingForPayment, expected = Status.Unconfirmed),
Model(status = ExpressOnrampStatus.PaymentProcessing, expected = Status.Unconfirmed),
Model(status = ExpressOnrampStatus.Verifying, expected = Status.Unconfirmed),
Model(status = ExpressOnrampStatus.Paid, expected = Status.Unconfirmed),
Model(status = ExpressOnrampStatus.Sending, expected = Status.Unconfirmed),
Model(status = ExpressOnrampStatus.Paused, expected = Status.Unconfirmed),
)
internal data class Model(val status: ExpressOnrampStatus, val expected: Status)
}