From 9bc5ee1684ea8266e70c95331b2f2655caf4fd2d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 11:33:10 +0300 Subject: [PATCH] Updated on 2026-08-14 --- features/txhistory/impl/build.gradle.kts | 2 + .../ExpressTxToDetailsUMConverter.kt | 445 ++++++++++++ .../OnChainTxToDetailsUMConverter.kt | 188 +++++ .../txhistory/converter/TxConverterHelpers.kt | 85 +++ ...HistoryInfoToTransactionItemUMConverter.kt | 2 +- ...istoryInfoToTxHistoryDetailsUMConverter.kt | 644 +----------------- ...HistoryItemToTransactionItemUMConverter.kt | 54 +- .../converter/TxHistoryStatusPillConverter.kt | 2 +- .../converter/TxHistoryTitleConverter.kt | 70 ++ .../txhistory/model/TxHistoryDetailsModel.kt | 50 +- .../txhistory/model/TxHistoryModel.kt | 13 +- 11 files changed, 896 insertions(+), 659 deletions(-) create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverter.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverter.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryTitleConverter.kt diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 7d678cb707..880d3281ca 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -45,6 +45,8 @@ dependencies { implementation(projects.domain.balanceHiding.models) implementation(projects.domain.account.status) implementation(projects.domain.onramp.models) + implementation(projects.domain.staking) + implementation(projects.domain.staking.models) /* AndroidX */ implementation(deps.androidx.activity.compose) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverter.kt new file mode 100644 index 0000000000..7200456354 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverter.kt @@ -0,0 +1,445 @@ +package com.tangem.features.txhistory.converter + +import androidx.annotation.StringRes +import com.tangem.common.ui.account.getResId +import com.tangem.common.ui.account.getUiColor +import com.tangem.common.ui.account.toUM +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.components.transactions.state.TxIcon +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.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20 +import com.tangem.core.ui.res.generated.icons.ic_card_20 +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.tokens.model.Amount +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +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.features.txhistory.model.ResolvedOwner +import com.tangem.features.txhistory.model.TxHistoryLookupContext +import com.tangem.features.txhistory.model.resolveOwner +import com.tangem.utils.StringsSigns +import com.tangem.utils.toBriefAddressFormat +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Converts an [ExpressTx] (swap / onramp) to the [TxHistoryDetailsUM.TwoAssets] details card. 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 ExpressTxToDetailsUMConverter( + private val onGoToProvider: (String) -> Unit, + private val lookup: TxHistoryLookupContext, + private val menu: ImmutableList, +) { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter() + private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter() + + fun convert(value: ExpressTx): TxHistoryDetailsUM.TwoAssets = when (value) { + is ExpressTx.Swap -> convertExpressSwap(value) + is ExpressTx.Onramp -> convertExpressOnramp(value) + } + + /** + * 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) + val fromOwner = resolveLegOwner(swap.tx.fromAddress, swap.tx.fromAsset.cryptoCurrency) + val toOwner = resolveLegOwner(swap.tx.payoutAddress, swap.tx.toAsset.cryptoCurrency) + return TxHistoryDetailsUM.TwoAssets( + header = TxHistoryDetailsUM.HeaderUM( + icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20), + status = status, + title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped), + subtitle = headerSubtitle(swap.timestampMillis), + menu = menu, + ), + from = swap.tx.fromAsset.toAssetUM( + label = ownerLabel(fromOwner, fallback = R.string.swapping_from_title_v2, owned = R.string.common_from), + owner = fromOwner, + sign = status.outgoingSign(), + isFaded = status is Status.Failed, + ), + to = swap.tx.toAsset.toAssetUM( + label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to), + owner = toOwner, + sign = status.incomingSign(), + isFaded = status is Status.Failed, + ), + statusBanner = swap.tx.status.toStatusBannerUM(), + rows = swap.toInfoRows(onProviderClick = swap.providerClick(), rateRow = swap.tx.swapRateRow()), + providerButton = providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()), + ) + } + + private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets { + val status = onrampStatusConverter.convert(onramp.tx.status) + val toOwner = resolveLegOwner(onramp.tx.payoutAddress, onramp.tx.toAsset.cryptoCurrency) + return TxHistoryDetailsUM.TwoAssets( + header = TxHistoryDetailsUM.HeaderUM( + icon = TxIcon.Vector(Icons.ic_card_20), + status = status, + title = status.statusAwareTitle( + R.string.tx_history_onramp_top_up, + R.string.tx_history_onramp_topped_up, + ), + subtitle = headerSubtitle(onramp.timestampMillis), + menu = menu, + ), + from = onramp.tx.fromFiat.toFiatAssetUM( + // The fiat side was paid from a card, not a portfolio address — no owner to resolve. + label = resourceReference(R.string.tx_history_you_paid), + isFaded = status is Status.Failed, + ), + to = onramp.tx.toAsset.toAssetUM( + label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to), + owner = toOwner, + sign = status.incomingSign(), + isFaded = status is Status.Failed, + ), + statusBanner = onramp.tx.status.toStatusBannerUM(), + rows = onramp.toInfoRows(onProviderClick = onramp.providerClick(), rateRow = onramp.tx.onrampRateRow()), + providerButton = providerButton(onramp.externalTxUrl, onramp.tx.status.providerButtonLabel()), + ) + } + + /** + * Resolves a swap/onramp leg's [address] (on the leg currency's network) to the owner shown under the amount: + * the user's own account / wallet, or the external [TxHistoryDetailsUM.AssetOwnerUM.Address] (e.g. a send-and-swap + * payout). `null` when there is no address to resolve (e.g. the very-old-version missing `fromAddress`, onramp fiat). + */ + private fun resolveLegOwner(address: String?, legCurrency: CryptoCurrency?): TxHistoryDetailsUM.AssetOwnerUM? { + if (address == null) return null + return when (val resolved = lookup.resolveOwner(address, legCurrency?.network?.id?.rawId)) { + is ResolvedOwner.OwnAccount -> TxHistoryDetailsUM.AssetOwnerUM.Account( + name = resolved.account.accountName.toUM().value, + iconResId = resolved.account.icon.value.getResId(), + backgroundColor = resolved.account.icon.color.getUiColor(), + ) + is ResolvedOwner.OwnWallet -> TxHistoryDetailsUM.AssetOwnerUM.Wallet( + name = stringReference(resolved.walletInfo.name), + deviceIconUM = resolved.walletInfo.deviceIconUM, + ) + is ResolvedOwner.External -> TxHistoryDetailsUM.AssetOwnerUM.Address( + name = stringReference(resolved.address.toBriefAddressFormat()), + rawAddress = resolved.address, + ) + } + } + + /** Leg caption: the direction-only [fallback] ("You send" / "You receive") without an owner, "From" / "To" with one. */ + private fun ownerLabel( + owner: TxHistoryDetailsUM.AssetOwnerUM?, + @StringRes fallback: Int, + @StringRes owned: Int, + ): TextReference = resourceReference(if (owner != null) owned else fallback) + + /** 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) }, + ) + } + + /** + * 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 ([TxHistoryDetailsUM.AssetUM.currencyIcon] = `null`). + */ + private fun ExpressTransactionAsset.toAssetUM( + label: TextReference, + owner: TxHistoryDetailsUM.AssetOwnerUM?, + sign: String, + isFaded: Boolean, + ): TxHistoryDetailsUM.AssetUM { + val symbol = displaySymbol + val formatted = amount.format { crypto( + symbol = symbol, + decimals = decimals, + ignoreSymbolPosition = true, + ) }.trim() + return TxHistoryDetailsUM.AssetUM( + label = label, + owner = owner, + 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 = fiatCode + 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, + ) + } +} + +// region Status banners + +/** + * Express swap status → the status plaque under the two-asset block. + * + * 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 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 +} + +/** + * 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), + 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, +) + +// endregion + +// region Info rows (provider / rate / network fee) + +/** + * 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( + onProviderClick: (() -> Unit)?, + rateRow: TxHistoryDetailsUM.InfoRowUM?, +): ImmutableList = buildList { + provider?.let { add(it.providerRow(onProviderClick)) } + rateRow?.let { add(it) } + addAll(txInfo.toInfoRows()) +}.toImmutableList() + +private fun ExpressProvider.providerRow(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 = + (this as? OnChainTx.BSDK)?.txInfo?.toInfoRows() ?: persistentListOf() + +// 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.displaySymbol + val quoteSymbol = toAsset.displaySymbol + 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.displaySymbol + val fiatCode = fromFiat.fiatCode + 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 signs + +/** 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 -> "" +} + +// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverter.kt new file mode 100644 index 0000000000..4b4d4f1c14 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverter.kt @@ -0,0 +1,188 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.transactions.state.TxIcon +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.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20 +import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20 +import com.tangem.core.ui.res.generated.icons.ic_arrow_up_20 +import com.tangem.core.ui.res.generated.icons.ic_chart_line_vertical_20 +import com.tangem.core.ui.res.generated.icons.ic_document_20 +import com.tangem.core.ui.res.generated.icons.ic_stack_20 +import com.tangem.core.ui.res.generated.icons.ic_success_20 +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.staking.model.stakekit.Yield +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import com.tangem.features.txhistory.impl.R +import com.tangem.utils.StringsSigns +import com.tangem.utils.extensions.isZero +import com.tangem.utils.toBriefAddressFormat +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * Converts an on-chain [TxInfo] to the [TxHistoryDetailsUM.SingleAsset] details card (Receive / Send / Transfer / + * staking / yield-supply). A two-asset swap always surfaces as an [com.tangem.domain.txhistory.model.ExpressTx.Swap] + * (handled by [ExpressTxToDetailsUMConverter]); 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. + */ +internal class OnChainTxToDetailsUMConverter( + private val currency: CryptoCurrency, + private val onCopyAddress: (String) -> Unit, + private val menu: ImmutableList, + /** Staking validators of the viewed currency keyed by on-chain address; resolves the validator row's name/link. */ + private val validatorsByAddress: Map, + private val onOpenValidator: (String) -> Unit, + /** Own deposit addresses on the viewed currency's network — drives the on-chain own-vs-external transfer title. */ + private val ownAddresses: Set, +) { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val titleConverter = TxHistoryTitleConverter() + + fun convert(value: TxInfo): TxHistoryDetailsUM.SingleAsset = TxHistoryDetailsUM.SingleAsset( + header = value.toHeaderUM(), + amountBlock = value.toAmountBlockUM(), + counterparty = value.toCounterpartyUM(), + // Validator (staking) / protocol (yield-supply), then the network fee from the tx itself; rate is not surfaced. + rows = buildList { + value.validatorRow()?.let(::add) + value.protocolRow()?.let(::add) + addAll(value.toInfoRows()) + }.toImmutableList(), + ) + + /** + * Protocol row of a yield-supply tx: the DeFi protocol the funds are supplied to. The yield-supply product is a + * single hard-wired integration across the app (Aave — the [yield_module_provider][R.string.yield_module_provider] + * name), so the value is that constant provider rather than a per-tx resolved name. Mutually exclusive with + * [validatorRow] — a tx is either staking or yield-supply, never both. + */ + private fun TxInfo.protocolRow(): TxHistoryDetailsUM.InfoRowUM? { + if (type !is TransactionType.YieldSupply) return null + return TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.staking_validator), + value = resourceReference(R.string.yield_module_provider), + ) + } + + /** + * Validator row of a staking tx: its display [name][Yield.Validator.name] and a link to the validator page. Present + * only when the tx carries a validator address ([validatorAddress]) that resolves in [validatorsByAddress]; otherwise + * `null` (non-staking tx, an address the current yield doesn't list, or a chain that omits the validator address). + * The trailing arrow-link and tap are offered only when the validator has a [website][Yield.Validator.website]. + */ + private fun TxInfo.validatorRow(): TxHistoryDetailsUM.InfoRowUM? { + val validator = validatorAddress()?.let(validatorsByAddress::get) ?: return null + val website = validator.website?.ifBlank { null } + return TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.staking_validator), + value = stringReference(validator.name), + trailingIconRes = website?.let { R.drawable.ic_arrow_top_right_24 }, + onClick = website?.let { url -> { onOpenValidator(url) } }, + ) + } + + /** + * On-chain address of the validator a staking tx interacts with, or `null` for a non-staking tx or a staking tx that + * does not surface it. A `Vote` carries it in the type itself; other staking types expose it through the interaction + * or destination address typed as `Validator`. + */ + private fun TxInfo.validatorAddress(): String? = when (val txType = type) { + is TransactionType.Staking.Vote -> txType.validatorAddress + is TransactionType.Staking -> interactionValidatorAddress() ?: destinationValidatorAddress() + else -> null + } + + private fun TxInfo.interactionValidatorAddress(): String? = + (interactionAddressType as? TxInfo.InteractionAddressType.Validator)?.address + + private fun TxInfo.destinationValidatorAddress(): String? = when (val destination = destinationType) { + is TxInfo.DestinationType.Single -> (destination.addressType as? TxInfo.AddressType.Validator)?.address + is TxInfo.DestinationType.Multiple -> + destination.addressTypes.filterIsInstance().firstOrNull()?.address + } + + private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( + icon = headerIcon(), + status = status.toUiStatus(), + title = titleConverter.convert(this, isOwnTransfer = isOwnTransfer()), + subtitle = headerSubtitle(timestampInMillis), + menu = menu, + ) + + /** A transfer whose counterparty is one of the viewed currency's own deposit addresses reads "Transfer". */ + private fun TxInfo.isOwnTransfer(): Boolean { + val counterpartyAddress = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address + return counterpartyAddress != null && counterpartyAddress in ownAddresses + } + + private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( + currencyIcon = iconStateConverter.convert(currency), + amount = stringReference(signedAmount(currency)), + isFailed = status is TxInfo.TransactionStatus.Failed, + ) + + /** + * Counterparty card ("Recipient" / "From"). 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); a counterparty that + * is not a plain external `User` address yields no card (`null`). + * + * The lookup needed to resolve an own-account / own-wallet avatar here is already available (it drives the + * swap/onramp leg owners), but applying it to the single-asset counterparty card is intentionally out of scope for + * now — a follow-up. + */ + 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) +} + +// region Amount / header 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() +} + +/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */ +private fun TxInfo.headerIcon(): TxIcon = when (type) { + is TransactionType.Approve -> TxIcon.Vector(Icons.ic_success_20) + is TransactionType.Staking.Stake, + is TransactionType.Staking.Unstake, + is TransactionType.Staking.Restake, + -> TxIcon.Vector(Icons.ic_stack_20) + is TransactionType.YieldSupply -> TxIcon.Vector(Icons.ic_chart_line_vertical_20) + is TransactionType.Operation -> TxIcon.Vector(Icons.ic_document_20) + is TransactionType.Swap -> TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20) + else -> TxIcon.Vector(if (isOutgoing) Icons.ic_arrow_up_20 else Icons.ic_arrow_down_20) +} + +// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxConverterHelpers.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxConverterHelpers.kt index dee143b2e8..6119eb4a57 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxConverterHelpers.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxConverterHelpers.kt @@ -4,12 +4,24 @@ import androidx.annotation.StringRes 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.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_copy_24 +import com.tangem.core.ui.res.generated.icons.ic_globe_24 +import com.tangem.core.ui.res.generated.icons.ic_share_android_24 +import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.domain.express.models.ExpressTransactionAsset import com.tangem.domain.models.network.TxInfo import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.features.txhistory.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.joda.time.DateTime // region Status helpers @@ -46,4 +58,77 @@ internal val ExpressTransactionAsset.displaySymbol: String internal val Amount.fiatCode: String get() = (type as? AmountType.FiatType)?.code ?: currencySymbol +// endregion + +// region Details header helpers + +/** + * Header overflow context menu of the details card, shared by all transaction types. Each row is dropped when its + * action is absent: "Transaction ID" (copy; dropped when [onCopyTxId] is `null` — no id to copy), "Share" (dropped + * when [onShare] is `null`) and "Explore" (dropped when [onExplore] is `null`). An empty list leaves the header with + * no "•••" button. Repeat / Hide are not part of this iteration. + */ +internal fun buildDetailsMenu( + onCopyTxId: (() -> Unit)?, + onShare: (() -> Unit)?, + onExplore: (() -> Unit)?, +): ImmutableList = buildList { + onCopyTxId?.let { copy -> + add( + TxHistoryDetailsUM.MenuItemUM( + icon = Icons.ic_copy_24, + title = resourceReference(R.string.common_transaction_id), + onClick = copy, + ), + ) + } + onShare?.let { share -> + add( + TxHistoryDetailsUM.MenuItemUM( + icon = Icons.ic_share_android_24, + title = resourceReference(R.string.common_share), + onClick = share, + ), + ) + } + onExplore?.let { explore -> + add( + TxHistoryDetailsUM.MenuItemUM( + icon = Icons.ic_globe_24, + title = resourceReference(R.string.common_explore), + onClick = explore, + ), + ) + } +}.toImmutableList() + +internal 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") +} + +// endregion + +// region Network-fee row + +/** + * Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). Shared + * by the on-chain details card and the express card (which pulls the fee from its matched on-chain leg). + */ +internal fun TxInfo.toInfoRows(): ImmutableList = + listOfNotNull(feeRow()).toImmutableList() + +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 \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt index 830bc48f9b..9540d6d899 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt @@ -28,8 +28,8 @@ internal class TxHistoryInfoToTransactionItemUMConverter( private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) { is OnChainTx.BSDK -> when (val um = txInfoConverter.convert(value.txInfo)) { - // Content rows (transfer/swap/…) route through the details/explorer decision; pills stay on the explorer. is TransactionItemUM.Content -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) }) + is TransactionItemUM.Pill -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) }) else -> um } } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt index b9e0b9fe2c..2cb19157fc 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt @@ -1,634 +1,56 @@ package com.tangem.features.txhistory.converter -import androidx.annotation.StringRes -import com.tangem.common.ui.account.getResId -import com.tangem.common.ui.account.getUiColor -import com.tangem.common.ui.account.toUM -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.components.transactions.state.TxIcon -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.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.res.generated.icons.Icons -import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20 -import com.tangem.core.ui.res.generated.icons.ic_arrow_swap_horizontal_20 -import com.tangem.core.ui.res.generated.icons.ic_arrow_up_20 -import com.tangem.core.ui.res.generated.icons.ic_card_20 -import com.tangem.core.ui.res.generated.icons.ic_copy_24 -import com.tangem.core.ui.res.generated.icons.ic_globe_24 -import com.tangem.core.ui.res.generated.icons.ic_share_android_24 -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 -import com.tangem.domain.tokens.model.Amount +import com.tangem.domain.staking.model.stakekit.Yield 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.features.txhistory.model.ResolvedOwner import com.tangem.features.txhistory.model.TxHistoryLookupContext -import com.tangem.features.txhistory.model.resolveOwner -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 -import java.math.RoundingMode /** - * 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) 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]). + * Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card, dispatching to the + * per-shape converters: an [OnChainTx.BSDK] renders as [TxHistoryDetailsUM.SingleAsset] (see + * [OnChainTxToDetailsUMConverter]) while an [ExpressTx] (swap / onramp) renders as [TxHistoryDetailsUM.TwoAssets] (see + * [ExpressTxToDetailsUMConverter]). The header overflow menu is built once here and shared by both. */ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( - private val currency: CryptoCurrency, - private val onCopyAddress: (String) -> Unit, - private val onGoToProvider: (String) -> Unit, - private val onCopyTxId: (() -> Unit)? = null, - private val onShare: (() -> Unit)? = null, - private val onExplore: (() -> Unit)? = null, - private val lookup: TxHistoryLookupContext = TxHistoryLookupContext( + currency: CryptoCurrency, + onCopyAddress: (String) -> Unit, + onGoToProvider: (String) -> Unit, + onCopyTxId: (() -> Unit)? = null, + onShare: (() -> Unit)? = null, + onExplore: (() -> Unit)? = null, + lookup: TxHistoryLookupContext = TxHistoryLookupContext( ownAccountByNetwork = emptyMap(), isAccountsModeEnabled = false, walletInfoById = emptyMap(), ), + validatorsByAddress: Map = emptyMap(), + onOpenValidator: (String) -> Unit = {}, ) : Converter { - private val iconStateConverter = CryptoCurrencyToIconStateConverter() - private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter() - private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter() + private val menu = buildDetailsMenu(onCopyTxId, onShare, onExplore) - /** Own deposit addresses on the viewed currency's network — drives the on-chain own-vs-external transfer title. */ - private val ownAddresses: Set = - lookup.ownAccountByNetwork[currency.network.id.rawId]?.keys.orEmpty() + private val onChainConverter = OnChainTxToDetailsUMConverter( + currency = currency, + onCopyAddress = onCopyAddress, + menu = menu, + validatorsByAddress = validatorsByAddress, + onOpenValidator = onOpenValidator, + // Own deposit addresses on the viewed currency's network — drives the on-chain own-vs-external transfer title. + ownAddresses = lookup.ownAccountByNetwork[currency.network.id.rawId]?.keys.orEmpty(), + ) + + private val expressConverter = ExpressTxToDetailsUMConverter( + onGoToProvider = onGoToProvider, + lookup = lookup, + menu = menu, + ) override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) { - is OnChainTx.BSDK -> convertOnChain(value.txInfo) - is ExpressTx.Swap -> convertExpressSwap(value) - is ExpressTx.Onramp -> convertExpressOnramp(value) + is OnChainTx.BSDK -> onChainConverter.convert(value.txInfo) + is ExpressTx -> expressConverter.convert(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(), - // Network fee from the tx itself; rate is not surfaced (no data). - rows = value.toInfoRows(), - ) - - private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( - icon = headerIcon(), - status = status.toUiStatus(), - title = headerTitle(), - subtitle = headerSubtitle(timestampInMillis), - menu = buildMenu(), - ) - - /** - * Header overflow context menu, shared by all transaction types. Each row is dropped when its action is absent: - * "Transaction ID" (copy; dropped when [onCopyTxId] is `null` — no id to copy), "Share" (dropped when [onShare] is - * `null`) and "Explore" (dropped when [onExplore] is `null`). An empty list leaves the header with no "•••" button. - * Repeat / Hide are not part of this iteration. - */ - private fun buildMenu(): ImmutableList = buildList { - onCopyTxId?.let { copy -> - add( - TxHistoryDetailsUM.MenuItemUM( - icon = Icons.ic_copy_24, - title = resourceReference(R.string.common_transaction_id), - onClick = copy, - ), - ) - } - onShare?.let { share -> - add( - TxHistoryDetailsUM.MenuItemUM( - icon = Icons.ic_share_android_24, - title = resourceReference(R.string.common_share), - onClick = share, - ), - ) - } - onExplore?.let { explore -> - add( - TxHistoryDetailsUM.MenuItemUM( - icon = Icons.ic_globe_24, - title = resourceReference(R.string.common_explore), - onClick = explore, - ), - ) - } - }.toImmutableList() - - private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( - currencyIcon = iconStateConverter.convert(currency), - amount = stringReference(signedAmount(currency)), - isFailed = status is TxInfo.TransactionStatus.Failed, - ) - - /** - * Counterparty card ("Recipient" / "From"). 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); a counterparty that - * is not a plain external `User` address yields no card (`null`). - * - * The [lookup] needed to resolve an own-account / own-wallet avatar here is already available (it drives the - * swap/onramp leg owners), but applying it to the single-asset counterparty card is intentionally out of scope for - * now — a follow-up. - */ - 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) - - 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) - val fromOwner = resolveLegOwner(swap.tx.fromAddress, swap.tx.fromAsset.cryptoCurrency) - val toOwner = resolveLegOwner(swap.tx.payoutAddress, swap.tx.toAsset.cryptoCurrency) - return TxHistoryDetailsUM.TwoAssets( - header = TxHistoryDetailsUM.HeaderUM( - icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20), - status = status, - title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped), - subtitle = headerSubtitle(swap.timestampMillis), - menu = buildMenu(), - ), - from = swap.tx.fromAsset.toAssetUM( - label = ownerLabel(fromOwner, fallback = R.string.swapping_from_title_v2, owned = R.string.common_from), - owner = fromOwner, - sign = status.outgoingSign(), - isFaded = status is Status.Failed, - ), - to = swap.tx.toAsset.toAssetUM( - label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to), - owner = toOwner, - sign = status.incomingSign(), - isFaded = status is Status.Failed, - ), - statusBanner = swap.tx.status.toStatusBannerUM(), - rows = swap.toInfoRows(onProviderClick = swap.providerClick(), rateRow = swap.tx.swapRateRow()), - providerButton = providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()), - ) - } - - private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets { - val status = onrampStatusConverter.convert(onramp.tx.status) - val toOwner = resolveLegOwner(onramp.tx.payoutAddress, onramp.tx.toAsset.cryptoCurrency) - return TxHistoryDetailsUM.TwoAssets( - header = TxHistoryDetailsUM.HeaderUM( - icon = TxIcon.Vector(Icons.ic_card_20), - status = status, - title = status.statusAwareTitle( - R.string.tx_history_onramp_top_up, - R.string.tx_history_onramp_topped_up, - ), - subtitle = headerSubtitle(onramp.timestampMillis), - menu = buildMenu(), - ), - from = onramp.tx.fromFiat.toFiatAssetUM( - // The fiat side was paid from a card, not a portfolio address — no owner to resolve. - label = resourceReference(R.string.tx_history_you_paid), - isFaded = status is Status.Failed, - ), - to = onramp.tx.toAsset.toAssetUM( - label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to), - owner = toOwner, - sign = status.incomingSign(), - isFaded = status is Status.Failed, - ), - statusBanner = onramp.tx.status.toStatusBannerUM(), - rows = onramp.toInfoRows(onProviderClick = onramp.providerClick(), rateRow = onramp.tx.onrampRateRow()), - providerButton = providerButton(onramp.externalTxUrl, onramp.tx.status.providerButtonLabel()), - ) - } - - /** - * Resolves a swap/onramp leg's [address] (on the leg currency's network) to the owner shown under the amount: - * the user's own account / wallet, or the external [TxHistoryDetailsUM.AssetOwnerUM.Address] (e.g. a send-and-swap - * payout). `null` when there is no address to resolve (e.g. the very-old-version missing `fromAddress`, onramp fiat). - */ - private fun resolveLegOwner(address: String?, legCurrency: CryptoCurrency?): TxHistoryDetailsUM.AssetOwnerUM? { - if (address == null) return null - return when (val resolved = lookup.resolveOwner(address, legCurrency?.network?.id?.rawId)) { - is ResolvedOwner.OwnAccount -> TxHistoryDetailsUM.AssetOwnerUM.Account( - name = resolved.account.accountName.toUM().value, - iconResId = resolved.account.icon.value.getResId(), - backgroundColor = resolved.account.icon.color.getUiColor(), - ) - is ResolvedOwner.OwnWallet -> TxHistoryDetailsUM.AssetOwnerUM.Wallet( - name = stringReference(resolved.walletInfo.name), - deviceIconUM = resolved.walletInfo.deviceIconUM, - ) - is ResolvedOwner.External -> TxHistoryDetailsUM.AssetOwnerUM.Address( - name = stringReference(resolved.address.toBriefAddressFormat()), - rawAddress = resolved.address, - ) - } - } - - /** Leg caption: the direction-only [fallback] ("You send" / "You receive") without an owner, "From" / "To" with one. */ - private fun ownerLabel( - owner: TxHistoryDetailsUM.AssetOwnerUM?, - @StringRes fallback: Int, - @StringRes owned: Int, - ): TextReference = resourceReference(if (owner != null) owned else fallback) - - /** 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) }, - ) - } - - /** - * 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, - owner: TxHistoryDetailsUM.AssetOwnerUM?, - sign: String, - isFaded: Boolean, - ): TxHistoryDetailsUM.AssetUM { - val symbol = displaySymbol - val formatted = amount.format { crypto( - symbol = symbol, - decimals = decimals, - ignoreSymbolPosition = true, - ) }.trim() - return TxHistoryDetailsUM.AssetUM( - label = label, - owner = owner, - 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 = fiatCode - 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, - ) - } - - // endregion -} - -// region Status helpers - -/** - * Express swap status → the status plaque under the two-asset block. - * - * 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 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 -} - -/** - * 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), - 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, -) - -// endregion - -// region Info rows (provider / rate / network fee) - -/** Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). */ -private fun TxInfo.toInfoRows(): ImmutableList = listOfNotNull(feeRow()).toImmutableList() - -/** - * Detail rows of an express op, 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( - onProviderClick: (() -> Unit)?, - rateRow: TxHistoryDetailsUM.InfoRowUM?, -): ImmutableList = buildList { - provider?.let { add(it.providerRow(onProviderClick)) } - rateRow?.let { add(it) } - addAll(txInfo.toInfoRows()) -}.toImmutableList() - -private fun ExpressProvider.providerRow(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 = - (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 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.displaySymbol - val quoteSymbol = toAsset.displaySymbol - 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.displaySymbol - val fiatCode = fromFiat.fiatCode - 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. */ -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 - * 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(): TxIcon = when (type) { - is TransactionType.Swap -> TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20) - else -> TxIcon.Vector(if (isOutgoing) Icons.ic_arrow_up_20 else Icons.ic_arrow_down_20) -} - -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") -} - -// endregion \ No newline at end of file +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt index 1f894d8563..0df485e16e 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt @@ -9,7 +9,6 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Conten import com.tangem.core.ui.components.transactions.state.TxIcon 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.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_arrow_down_20 @@ -51,6 +50,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( private val lookupContext: TxHistoryLookupContext? = null, ) : Converter { + private val titleConverter = TxHistoryTitleConverter() private val pillConverter = TxHistoryStatusPillConverter(currency, txHistoryUiActions) @Suppress("CyclomaticComplexMethod") @@ -69,7 +69,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( // endregion // region Content - is TransactionType.Operation -> operationContent(value, uiStatus, type) + is TransactionType.Operation -> operationContent(value, uiStatus) is TransactionType.Swap -> swapContent(value, uiStatus) is TransactionType.Transfer -> transferContent(value, uiStatus) is TransactionType.Staking.ClaimRewards -> claimRewardsContent(value, uiStatus) @@ -84,23 +84,20 @@ internal class TxHistoryItemToTransactionItemUMConverter( } } - private fun operationContent( - tx: TxInfo, - uiStatus: TransactionItemUM.Content.Status, - type: TransactionType.Operation, - ): TransactionItemUM.Content = buildContent( - tx = tx, - uiStatus = uiStatus, - title = stringReference(type.name), - icon = tx.directionalIcon(), - subtitle = tx.extractAddressSubtitle(), - ) + private fun operationContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content = + buildContent( + tx = tx, + uiStatus = uiStatus, + title = titleConverter.convert(tx), + icon = TxIcon.Vector(Icons.ic_document_20), + subtitle = tx.extractAddressSubtitle(), + ) private fun swapContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content = buildContent( tx = tx, uiStatus = uiStatus, - title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped), + title = titleConverter.convert(tx), icon = tx.directionalIcon(), subtitle = tx.extractAddressSubtitle(), ) @@ -117,11 +114,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( ) } - val title = when { - ownSubtitle != null -> tx.statusAwareTitle(R.string.common_transfer, R.string.common_transferred) - tx.isOutgoing -> tx.statusAwareTitle(R.string.common_sending, R.string.common_sent) - else -> tx.statusAwareTitle(R.string.common_receiving, R.string.common_received) - } + val title = titleConverter.convert(tx, isOwnTransfer = ownSubtitle != null) val subtitle = ownSubtitle ?: when { counterpartyAddress != null -> ContentSubtitle.ExternalAddress( @@ -145,10 +138,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( buildContent( tx = tx, uiStatus = uiStatus, - title = tx.statusAwareTitle( - pending = R.string.transaction_history_claiming_reward, - confirmed = R.string.transaction_history_staking_reward, - ), + title = titleConverter.convert(tx), icon = TxIcon.Res(R.drawable.ic_transaction_history_claim_rewards_24), subtitle = ContentSubtitle.Plain(resourceReference(R.string.transaction_history_earned_from_stake)), ) @@ -160,7 +150,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( ): TransactionItemUM.Content = buildContent( tx = tx, uiStatus = uiStatus, - title = resourceReference(R.string.yield_module_transaction_topup), + title = titleConverter.convert(tx), icon = tx.directionalIcon(), subtitle = tx.yieldSupplySubtitle(currency, type), ) @@ -172,7 +162,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( ): TransactionItemUM.Content = buildContent( tx = tx, uiStatus = uiStatus, - title = resourceReference(R.string.yield_module_transaction_deploy_contract), + title = titleConverter.convert(tx), icon = TxIcon.Vector(Icons.ic_document_20), subtitle = tx.yieldSupplySubtitle(currency, type), ) @@ -184,7 +174,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( ): TransactionItemUM.Content = buildContent( tx = tx, uiStatus = uiStatus, - title = resourceReference(R.string.yield_module_transaction_initialize), + title = titleConverter.convert(tx), icon = TxIcon.Res(R.drawable.ic_gear_24), subtitle = tx.yieldSupplySubtitle(currency, type), ) @@ -196,7 +186,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( ): TransactionItemUM.Content = buildContent( tx = tx, uiStatus = uiStatus, - title = resourceReference(R.string.yield_module_transaction_reactivate), + title = titleConverter.convert(tx), icon = TxIcon.Vector(Icons.ic_arrow_refresh_20), subtitle = tx.yieldSupplySubtitle(currency, type), ) @@ -208,11 +198,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( ): TransactionItemUM.Content = buildContent( tx = tx, uiStatus = uiStatus, - title = if (type.isYieldSupplyWithdraw || tx.isOutgoing) { - resourceReference(R.string.yield_module_transaction_withdraw) - } else { - resourceReference(R.string.common_transfer) - }, + title = titleConverter.convert(tx), icon = tx.directionalIcon(), subtitle = tx.yieldSupplySubtitle(currency, type), hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing, @@ -224,7 +210,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( ): TransactionItemUM.Content = buildContent( tx = tx, uiStatus = uiStatus, - title = resourceReference(R.string.transaction_history_operation), + title = titleConverter.convert(tx), icon = tx.directionalIcon(), subtitle = tx.extractAddressSubtitle(), ) @@ -233,7 +219,7 @@ internal class TxHistoryItemToTransactionItemUMConverter( buildContent( tx = tx, uiStatus = uiStatus, - title = resourceReference(R.string.gasless_transaction_fee), + title = titleConverter.convert(tx), icon = tx.directionalIcon(), subtitle = tx.extractAddressSubtitle(), ) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverter.kt index 75d7806ebd..cb614d57c0 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverter.kt @@ -146,7 +146,7 @@ private fun TxInfo.buildPillSubtitle(status: TransactionItemUM.Content.Status): ) } -private fun PillLabels.resolve(status: TransactionItemUM.Content.Status): TextReference = when (status) { +internal fun PillLabels.resolve(status: TransactionItemUM.Content.Status): TextReference = when (status) { is TransactionItemUM.Content.Status.Confirmed -> resourceReference(confirmed) is TransactionItemUM.Content.Status.Unconfirmed -> resourceReference(pending) is TransactionItemUM.Content.Status.Failed -> if (hasFailedTemplate) { diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryTitleConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryTitleConverter.kt new file mode 100644 index 0000000000..657300d990 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryTitleConverter.kt @@ -0,0 +1,70 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.features.txhistory.impl.R + +/** + * Single source of truth for an on-chain [TxInfo]'s human-readable title, dispatched on [TransactionType]. + * + * Shared by the history list (row content title and status-pill label) and the transaction-details header, so the two + * never drift apart. Pill types (Approve / Staking.* / YieldSupply Enter|Exit) resolve to the status-aware pill label + * text only — the same wording shown in the list pill, without its trailing amount (e.g. "Staking", "Restaking", + * "Yield mode"). Content types reproduce the per-type row titles. + */ +internal class TxHistoryTitleConverter { + + /** + * @param tx transaction whose title to resolve. + * @param isOwnTransfer for a [TransactionType.Transfer] only — whether the counterparty is one of the user's own + * accounts/wallets, which selects the "Transfer" title over "Send" / "Receive". + */ + @Suppress("CyclomaticComplexMethod", "LongMethod") + fun convert(tx: TxInfo, isOwnTransfer: Boolean = false): TextReference = when (val type = tx.type) { + // region Pills — the label text only (no amount), matching what the list pill shows + is TransactionType.Approve -> tx.pillTitle(ApproveSpec) + is TransactionType.Staking.Stake -> tx.pillTitle(StakeSpec) + is TransactionType.Staking.Unstake -> tx.pillTitle(UnstakeSpec) + is TransactionType.Staking.Restake -> tx.pillTitle(RestakeSpec) + is TransactionType.Staking.Vote -> tx.pillTitle(VoteSpec) + is TransactionType.Staking.Withdraw -> tx.pillTitle(WithdrawSpec) + is TransactionType.YieldSupply.Enter -> tx.pillTitle(YieldEnterSpec) + is TransactionType.YieldSupply.Exit -> tx.pillTitle(YieldExitSpec) + // endregion + + // region Content + is TransactionType.Operation -> stringReference(type.name) + is TransactionType.Swap -> tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped) + is TransactionType.Transfer -> tx.transferTitle(isOwnTransfer) + is TransactionType.Staking.ClaimRewards -> tx.statusAwareTitle( + pending = R.string.transaction_history_claiming_reward, + confirmed = R.string.transaction_history_staking_reward, + ) + is TransactionType.YieldSupply.Topup -> resourceReference(R.string.yield_module_transaction_topup) + is TransactionType.YieldSupply.Send -> if (type.isYieldSupplyWithdraw || tx.isOutgoing) { + resourceReference(R.string.yield_module_transaction_withdraw) + } else { + resourceReference(R.string.common_transfer) + } + is TransactionType.YieldSupply.DeployContract -> + resourceReference(R.string.yield_module_transaction_deploy_contract) + is TransactionType.YieldSupply.InitializeToken -> + resourceReference(R.string.yield_module_transaction_initialize) + is TransactionType.YieldSupply.ReactivateToken -> + resourceReference(R.string.yield_module_transaction_reactivate) + is TransactionType.UnknownOperation -> resourceReference(R.string.transaction_history_operation) + is TransactionType.GaslessFee -> resourceReference(R.string.gasless_transaction_fee) + // endregion + } + + private fun TxInfo.transferTitle(isOwnTransfer: Boolean): TextReference = 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) + } + + private fun TxInfo.pillTitle(spec: PillSpec): TextReference = spec.labels.resolve(status.toUiStatus()) +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt index 16b72c8b15..a241c201c2 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -7,6 +7,11 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.staking.GetYieldUseCase +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.domain.txhistory.model.explorerHash import com.tangem.domain.txhistory.model.idToCopy import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase @@ -15,10 +20,14 @@ import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUM import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger +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.onStart import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -31,16 +40,34 @@ internal class TxHistoryDetailsModel @Inject constructor( private val urlOpener: UrlOpener, private val shareManager: ShareManager, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, + private val getYieldUseCase: GetYieldUseCase, ownerLookupProducer: TxHistoryOwnerLookupProducer, paramsContainer: ParamsContainer, ) : Model() { private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() + /** + * Validators of the viewed currency's staking yield, keyed by on-chain address. Used to resolve the validator + * address carried by a staking [TxInfo] to its display name and page. Empty when the currency has no staking yield + * (the use case reads the prefetched yields cache and fails gracefully for non-staking / custom tokens). + * + * The yield is fetched only when the viewed tx is a staking op ([requiresValidatorLookup]) — a Send / Swap / onramp + * has no validator to resolve, so it skips the use case entirely and stays on the empty map. + */ + private val validatorsByAddress: Flow> = params.txHistoryInfo + .map { it.requiresValidatorLookup() } + .distinctUntilChanged() + .map { requiresLookup -> if (requiresLookup) loadValidators() else emptyMap() } + .onStart { emit(emptyMap()) } + .distinctUntilChanged() + .flowOn(dispatchers.io) + val uiState: StateFlow = combine( params.txHistoryInfo, ownerLookupProducer(), - ) { txInfo, lookup -> + validatorsByAddress, + ) { txInfo, lookup, validators -> // No explorer hash (e.g. an express op with no on-chain leg yet, or a blank on-chain hash) → the "Share" and // "Explore" rows are dropped; a blank id drops the "Transaction ID" row. val explorerHash = txInfo.explorerHash?.ifBlank { null } @@ -53,11 +80,32 @@ internal class TxHistoryDetailsModel @Inject constructor( onShare = explorerHash?.let { hash -> { share(hash) } }, onExplore = explorerHash?.let { hash -> { explore(hash) } }, lookup = lookup, + validatorsByAddress = validators, + onOpenValidator = urlOpener::openUrl, ).convert(txInfo) } .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null) + /** + * Resolves the viewed currency's staking validators into an address-keyed map. Returns empty when the currency has + * no yield (non-staking / custom token) — the use case surfaces that as a [Left] which we treat as "no validators". + */ + private suspend fun loadValidators(): Map = getYieldUseCase( + cryptoCurrencyId = params.currency.id, + symbol = params.currency.symbol, + ).fold( + ifLeft = { emptyMap() }, + ifRight = { yield -> yield.validators.associateBy(Yield.Validator::address) }, + ) + + /** + * Whether the row is an on-chain staking op — the only case whose validator address can resolve to a validator. + * A non-staking on-chain tx, or any express row, carries no validator, so the yield lookup is skipped. + */ + private fun TxHistoryInfo.requiresValidatorLookup(): Boolean = + (this as? OnChainTx.BSDK)?.txInfo?.type is TxInfo.TransactionType.Staking + /** Copies a counterparty address to the clipboard — wired into the detail card's copy button via the converter. */ private fun onCopyAddress(address: String) { clipboardManager.setText(text = address, isSensitive = false) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index b890e9a019..13b5bac325 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -12,9 +12,6 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.txhistory.model.ExpressTx -import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.TxHistoryFeatureToggles import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger @@ -337,18 +334,12 @@ internal class TxHistoryModel @Inject constructor( } override fun onTransactionClick(item: TxHistoryInfo) { - // manager is non-null only under the new tx-history toggle; on the legacy path every tap falls to the explorer. + // manager is non-null only under the new tx-history toggle. val manager = historyTxListManager - if (manager != null && item.opensInAppDetails()) { + if (manager != null) { params.onTxDetailsRequested(manager.txHistoryInfoFlow(item)) } else { item.explorerHash?.let(::openTxInExplorer) } } -} - -/** On-chain transfers/swaps and every express op open the in-app details sheet; everything else goes to the explorer. */ -private fun TxHistoryInfo.opensInAppDetails(): Boolean = when (this) { - is ExpressTx -> true - is OnChainTx.BSDK -> txInfo.type is TxInfo.TransactionType.Transfer || txInfo.type is TxInfo.TransactionType.Swap } \ No newline at end of file