From e1c58bed0ece0db2511e15950da657fc1c651aec Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Jul 2026 12:47:07 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../ExpressTxHistoryConverterTest.kt | 48 +++++++- .../DefaultTokenDetailsComponent.kt | 4 + .../tokendetails/model/TokenDetailsModel.kt | 5 + .../component/TxHistoryDetailsComponent.kt | 1 + features/txhistory/impl/build.gradle.kts | 1 + .../ExpressTxToDetailsUMConverter.kt | 57 ++++++++-- .../OnChainTxToDetailsUMConverter.kt | 41 +++++-- ...istoryInfoToTxHistoryDetailsUMConverter.kt | 6 + .../txhistory/entity/TxHistoryDetailsUM.kt | 3 +- .../txhistory/model/TxHistoryDetailsModel.kt | 84 +++++++++++++- ...TxHistoryDetailsModalBottomSheetContent.kt | 45 +++++++- .../ui/TxHistoryDetailsStatusBanner.kt | 29 ++++- .../ExpressTxToDetailsUMConverterTest.kt | 22 +++- .../OnChainTxToDetailsUMConverterTest.kt | 50 +++++++++ ...ryInfoToTxHistoryDetailsUMConverterTest.kt | 103 ++++++++++++++++++ 15 files changed, 467 insertions(+), 32 deletions(-) diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt index c3dbf469b3..bf2f38154d 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverterTest.kt @@ -3,6 +3,7 @@ package com.tangem.data.txhistory.repository.converter import com.google.common.truth.Truth.assertThat import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity +import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.express.models.ExpressExchangeStatus import com.tangem.domain.express.models.ExpressOnrampStatus import com.tangem.domain.tokens.model.AmountType @@ -88,6 +89,47 @@ internal class ExpressTxHistoryConverterTest { assertThat(swap.tx.toAsset.amount).isEquivalentAccordingToCompareTo(BigDecimal("0.00099")) } + @Test + fun `GIVEN exchange entity with refund fields WHEN convert THEN refund asset id mapped to domain`() { + // Arrange + val entity = createExchangeEntity(refundNetwork = "polygon", refundContractAddress = "0xrefund") + + // Act + val swap = swapConverter.convert( + ExpressSwapConverter.Input( + entity = entity, + provider = null, + isOutgoing = true, + fromCurrency = null, + toCurrency = null, + refundCurrency = null, + ), + ) + + // Assert + assertThat(swap.tx.refundAssetId) + .isEqualTo(ExpressAsset.ID(networkId = "polygon", contractAddress = "0xrefund")) + } + + @Test + fun `GIVEN exchange entity without refund fields WHEN convert THEN refund fields are null`() { + // Act + val swap = swapConverter.convert( + ExpressSwapConverter.Input( + entity = createExchangeEntity(), + provider = null, + isOutgoing = true, + fromCurrency = null, + toCurrency = null, + refundCurrency = null, + ), + ) + + // Assert + assertThat(swap.tx.refundAssetId).isNull() + assertThat(swap.tx.refundCurrency).isNull() + } + @Test fun `GIVEN onramp entity WHEN toOnramp THEN matched by payout hash with fiat from-leg`() { // Arrange @@ -114,6 +156,8 @@ internal class ExpressTxHistoryConverterTest { // Raw minimal-unit amount (to-asset decimals = 8) → 0.001 toAmount: String = "100000", toActualAmount: String? = null, + refundNetwork: String? = null, + refundContractAddress: String? = null, ) = ExpressExchangeEntity( txId = "tx-1", providerId = "provider", @@ -129,8 +173,8 @@ internal class ExpressTxHistoryConverterTest { externalTxUrl = "https://ex.url", payinHash = payinHash, payoutHash = payoutHash, - refundNetwork = null, - refundContractAddress = null, + refundNetwork = refundNetwork, + refundContractAddress = refundContractAddress, createdAt = CREATED_AT, updatedAt = CREATED_AT, payTill = null, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index ddc96c2d4b..b8a431f274 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -108,6 +108,10 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( userWalletId = params.userWalletId, currency = params.currency, onDismiss = model.txDetailsNavigation::dismiss, + onOpenTokenDetails = { currency -> + model.txDetailsNavigation.dismiss() + model.openTokenDetails(currency) + }, ), ) }, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index f27d73d86f..72736bf01e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -663,6 +663,11 @@ internal class TokenDetailsModel @Inject constructor( router.openTokenDetails(userWalletId = userWalletId, currency = cryptoCurrency) } + /** Opens the given currency's Token Details on top of this screen (e.g. the refunded token from the tx details sheet). */ + fun openTokenDetails(currency: CryptoCurrency) { + router.openTokenDetails(userWalletId = userWalletId, currency = currency) + } + override fun onStakeBannerClick() { analyticsEventsHandler.send(TokenScreenAnalyticsEvent.StakingClicked(cryptoCurrency.symbol)) openStaking() diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt index add3fd0778..f8c1ea9911 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt @@ -14,6 +14,7 @@ interface TxHistoryDetailsComponent : ComposableBottomSheetComponent { val userWalletId: UserWalletId, val currency: CryptoCurrency, val onDismiss: () -> Unit, + val onOpenTokenDetails: (CryptoCurrency) -> Unit, ) interface Factory : ComponentFactory diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 860a640aaf..3a5bbe5a18 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { /** Common */ api(projects.common.ui) + implementation(projects.common) /** Features api */ api(projects.features.txhistory.api) 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 index 7200456354..df1215195b 100644 --- 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 @@ -1,6 +1,8 @@ package com.tangem.features.txhistory.converter import androidx.annotation.StringRes +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration import com.tangem.common.ui.account.getResId import com.tangem.common.ui.account.getUiColor import com.tangem.common.ui.account.toUM @@ -9,7 +11,10 @@ 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.plus import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.styledResourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -49,6 +54,9 @@ internal class ExpressTxToDetailsUMConverter( private val onGoToProvider: (String) -> Unit, private val lookup: TxHistoryLookupContext, private val menu: ImmutableList, + private val refundCurrency: CryptoCurrency? = null, + private val onLearnMoreAboutRefundsClick: () -> Unit = {}, + private val onGoToRefundedTokenClick: (CryptoCurrency) -> Unit = {}, ) { private val iconStateConverter = CryptoCurrencyToIconStateConverter() @@ -68,6 +76,7 @@ internal class ExpressTxToDetailsUMConverter( 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) + val refundToken = refundCurrency.takeIf { swap.tx.status == ExpressExchangeStatus.Refunded } return TxHistoryDetailsUM.TwoAssets( header = TxHistoryDetailsUM.HeaderUM( icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20), @@ -79,8 +88,9 @@ internal class ExpressTxToDetailsUMConverter( 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, + sign = OUTGOING_SIGN, + // The spent leg always stands as sent — on a failed/refunded deal only the never-received leg fades. + isFaded = false, ), to = swap.tx.toAsset.toAssetUM( label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to), @@ -88,12 +98,40 @@ internal class ExpressTxToDetailsUMConverter( sign = status.incomingSign(), isFaded = status is Status.Failed, ), - statusBanner = swap.tx.status.toStatusBannerUM(), + statusBanner = refundToken?.let(::refundedInBanner) ?: swap.tx.status.toStatusBannerUM(), rows = swap.toInfoRows(onProviderClick = swap.providerClick(), rateRow = swap.tx.swapRateRow()), - providerButton = providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()), + providerButton = refundToken?.let(::goToRefundedTokenButton) + ?: providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()), ) } + /** + * Refunded terminal with a resolved refund token: the red "Refunded in {symbol}" plaque with the token/network + * explanation and the underlined "Learn more" link appended to the subtitle. + */ + private fun refundedInBanner(refundToken: CryptoCurrency) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Error, + title = resourceReference( + id = R.string.express_exchange_notification_refunded_in_title, + formatArgs = wrappedList(refundToken.symbol), + ), + subtitle = resourceReference( + id = R.string.express_exchange_notification_refunded_in_text, + formatArgs = wrappedList(refundToken.symbol, refundToken.network.name), + ) + stringReference(" ") + styledResourceReference( + id = R.string.common_learn_more, + spanStyleReference = { SpanStyle(textDecoration = TextDecoration.Underline) }, + onClick = onLearnMoreAboutRefundsClick, + ), + isLoading = false, + ) + + /** Bottom "Go to token" CTA of the refunded terminal — opens the refund token's details. */ + private fun goToRefundedTokenButton(refundToken: CryptoCurrency) = TxHistoryDetailsUM.ProviderButtonUM( + text = resourceReference(R.string.common_go_to_token), + onClick = { onGoToRefundedTokenClick(refundToken) }, + ) + 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) @@ -217,10 +255,11 @@ internal class ExpressTxToDetailsUMConverter( * 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 + * (KYC) and the paused terminal as [Severity.Warning]; the failure and refunded 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`). + * plaque (`null`). The [Refunded][ExpressExchangeStatus.Refunded] mapping here is the fallback for an unresolved + * refund token — with a resolved one the converter builds the richer "Refunded in {symbol}" plaque instead. */ private fun ExpressExchangeStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) { ExpressExchangeStatus.Preview, @@ -233,7 +272,7 @@ private fun ExpressExchangeStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusB 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.Refunded -> errorBanner(R.string.express_exchange_status_refunded) ExpressExchangeStatus.Paused -> warningBanner(R.string.express_exchange_status_paused) ExpressExchangeStatus.Failed, ExpressExchangeStatus.TxFailed, @@ -429,8 +468,8 @@ private fun BigDecimal?.takeIfPositive(): BigDecimal? = this?.takeIf { it > BigD // 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 pay-in / "You send" leg: always `−` — the funds left regardless of how the deal ended. */ +private const val OUTGOING_SIGN = "${StringsSigns.MINUS} " /** * Leading sign of the payout / "You receive" leg: `~` while in flight (the final received amount is still an estimate), 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 index 708a11ce0a..85df497338 100644 --- 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 @@ -194,20 +194,47 @@ internal class OnChainTxToDetailsUMConverter( // region Amount / header building helpers +/** How the header amount is signed, decided by the transaction type. */ +private enum class AmountSign { + + /** `-` for outgoing, `+` for incoming — plain value transfers. */ + BY_DIRECTION, + + /** Always `+` — an inflow regardless of the reported direction (staking rewards). */ + ALWAYS_PLUS, + + /** + * No sign — protocol interactions (staking, approvals, yield-supply enter/exit) whose amount is a parameter of the + * operation, not a transfer in/out of the account. + */ + NONE, +} + +private fun TransactionType.amountSign(): AmountSign = when (this) { + is TransactionType.Staking.ClaimRewards -> AmountSign.ALWAYS_PLUS + is TransactionType.Staking, + is TransactionType.Approve, + is TransactionType.YieldSupply.Enter, + is TransactionType.YieldSupply.Exit, + -> AmountSign.NONE + else -> AmountSign.BY_DIRECTION +} + /** - * 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, for the failed state (a failed tx moved nothing) and for yield-supply - * enter/exit (which reads "Supplied"/"Returned" via the label instead of a signed transfer) — the UI then only strikes - * the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed]. + * Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is decided per transaction + * type by [amountSign], 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 -> "" - type is TransactionType.YieldSupply.Enter || type is TransactionType.YieldSupply.Exit -> "" amount.isZero() -> "" - isOutgoing -> "${StringsSigns.MINUS} " - else -> "${StringsSigns.PLUS} " + else -> when (type.amountSign()) { + AmountSign.BY_DIRECTION -> if (isOutgoing) "${StringsSigns.MINUS} " else "${StringsSigns.PLUS} " + AmountSign.ALWAYS_PLUS -> "${StringsSigns.PLUS} " + AmountSign.NONE -> "" + } } return (prefix + formatted).trim() } 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 20ddaf3c14..e9737b956c 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 @@ -22,6 +22,9 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( onCopyTxId: (() -> Unit)? = null, onShare: (() -> Unit)? = null, onExplore: (() -> Unit)? = null, + refundCurrency: CryptoCurrency? = null, + onLearnMoreAboutRefundsClick: () -> Unit = {}, + onGoToRefundedTokenClick: (CryptoCurrency) -> Unit = {}, lookup: TxHistoryLookupContext = TxHistoryLookupContext( ownAccountByNetwork = emptyMap(), isAccountsModeEnabled = false, @@ -47,6 +50,9 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( onGoToProvider = onGoToProvider, lookup = lookup, menu = menu, + refundCurrency = refundCurrency, + onLearnMoreAboutRefundsClick = onLearnMoreAboutRefundsClick, + onGoToRefundedTokenClick = onGoToRefundedTokenClick, ) override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) { diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index dabb48ccb8..ffd9fdda30 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -59,7 +59,8 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * * @property severity Plaque colors (background tint + text/icon color). * @property title Status line, e.g. "Awaiting funds" / "Confirmed" / "Failed". - * @property subtitle Optional second line (e.g. the refund hint on a failed terminal). + * @property subtitle Optional second line (e.g. the refund hint on a failed terminal). May carry a styled + * tappable part (e.g. the "Learn more" of the refunded terminal) — rendered as an annotated reference. * @property isLoading `true` → trailing rotating loader (in-progress); `false` → static [severity] glyph. */ data class StatusBannerUM( 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 a241c201c2..78bf6441ac 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 @@ -1,15 +1,25 @@ package com.tangem.features.txhistory.model import androidx.compose.runtime.Stable +import arrow.core.getOrElse +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.models.currency.CryptoCurrency 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.ExpressTx import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.domain.txhistory.model.explorerHash @@ -21,14 +31,18 @@ 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.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -41,6 +55,8 @@ internal class TxHistoryDetailsModel @Inject constructor( private val shareManager: ShareManager, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val getYieldUseCase: GetYieldUseCase, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, + private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, ownerLookupProducer: TxHistoryOwnerLookupProducer, paramsContainer: ParamsContainer, ) : Model() { @@ -63,11 +79,28 @@ internal class TxHistoryDetailsModel @Inject constructor( .distinctUntilChanged() .flowOn(dispatchers.io) + /** + * The portfolio token a refunded DEX-bridge swap was refunded in — drives the "Refunded in {token}" terminal. + * `null` while unresolved or when the deal carries no refund token. + */ + private val refundCurrency = MutableStateFlow(null) + + init { + // One-shot: the portfolio add must not re-run when the UI resubscribes. + modelScope.launch(dispatchers.default) { + val refundAssetId = params.txHistoryInfo + .mapNotNull { it.bridgeRefundTx()?.refundAssetId } + .first() + refundCurrency.value = addRefundTokenToPortfolio(refundAssetId) + } + } + val uiState: StateFlow = combine( - params.txHistoryInfo, - ownerLookupProducer(), - validatorsByAddress, - ) { txInfo, lookup, validators -> + flow = params.txHistoryInfo, + flow2 = ownerLookupProducer(), + flow3 = validatorsByAddress, + flow4 = refundCurrency, + ) { txInfo, lookup, validators, refundToken -> // 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 } @@ -79,6 +112,9 @@ internal class TxHistoryDetailsModel @Inject constructor( onCopyTxId = idToCopy?.let { id -> { onCopyTxId(id) } }, onShare = explorerHash?.let { hash -> { share(hash) } }, onExplore = explorerHash?.let { hash -> { explore(hash) } }, + refundCurrency = refundToken, + onLearnMoreAboutRefundsClick = ::onLearnMoreAboutRefunds, + onGoToRefundedTokenClick = params.onOpenTokenDetails, lookup = lookup, validatorsByAddress = validators, onOpenValidator = urlOpener::openUrl, @@ -131,4 +167,44 @@ internal class TxHistoryDetailsModel @Inject constructor( ifRight = { shareManager.shareText(text = it) }, ) } + + /** Opens the cross-chain-bridges blog article — wired into the refunded banner's "Learn more" link. */ + private fun onLearnMoreAboutRefunds() { + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.AboutCrossChainBridges)) + } + } + + /** + * Adds the refund token to the account of the viewed currency and returns it (idempotent); + * `null` when the account or the token cannot be resolved (e.g. offline). + */ + private suspend fun addRefundTokenToPortfolio(ref: ExpressAsset.ID): CryptoCurrency? { + val accountId = getAccountCurrencyStatusUseCase + .invokeSync(userWalletId = params.userWalletId, currency = params.currency) + .map { it.account.accountId } + .getOrElse { + TangemLogger.e("Unable to resolve account for refund token ${params.currency.id}") + return null + } + + return manageCryptoCurrenciesUseCase.add( + accountId = accountId, + networkId = ref.networkId, + contractAddress = ref.contractAddress, + ) + .onLeft { TangemLogger.e("Unable to resolve refund token", it) } + .getOrNull() + } +} + +/** + * The deal of a refunded DEX-bridge swap; `null` for everything else. Only a DEX-bridge deal is refunded in an + * intermediate token, so other provider types must not surface the "Refunded in {token}" terminal. + */ +private fun TxHistoryInfo.bridgeRefundTx(): ExchangeTransaction? { + val tx = (this as? ExpressTx.Swap)?.tx ?: return null + if (tx.status != ExpressExchangeStatus.Refunded) return null + if (tx.provider?.type != ExpressProviderType.DEX_BRIDGE) return null + return tx } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt index 355be8711f..2f5c338588 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt @@ -2,6 +2,8 @@ package com.tangem.features.txhistory.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R @@ -11,7 +13,9 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet import com.tangem.core.ui.components.currency.icon.CurrencyIconState 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.plus import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.styledStringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons @@ -68,6 +72,15 @@ private fun TxHistoryDetailsModalBottomSheetContentTwoAssetsPreview() { } } +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun TxHistoryDetailsModalBottomSheetContentRefundedPreview() { + TangemThemePreviewRedesign { + TxHistoryDetailsModalBottomSheetContent(state = previewRefunded(), onDismiss = {}) + } +} + /** Fully-populated single-asset state exercising every sub-view: header, amount block, counterparty and info rows. */ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset( header = TxHistoryDetailsUM.HeaderUM( @@ -122,12 +135,12 @@ private fun previewTwoAssets() = TxHistoryDetailsUM.TwoAssets( isGrayscale = false, shouldShowCustomBadge = false, ), - isFaded = true, + isFaded = false, ), to = TxHistoryDetailsUM.AssetUM( label = stringReference("You receive"), owner = null, - amount = stringReference("+ 0.001 BTC"), + amount = stringReference("0.001 BTC"), currencyIcon = CurrencyIconState.CoinIcon( url = null, fallbackResId = R.drawable.img_btc_22, @@ -157,6 +170,34 @@ private fun previewTwoAssets() = TxHistoryDetailsUM.TwoAssets( ), ) +/** Refunded swap exercising the "Refunded in {symbol}" banner with the "Learn more" link and the "Go to token" CTA. */ +private fun previewRefunded() = previewTwoAssets().copy( + header = TxHistoryDetailsUM.HeaderUM( + icon = TxIcon.Vector(Icons.ic_arrow_swap_horizontal_20), + status = Status.Failed, + title = stringReference("Swapping failed"), + subtitle = stringReference("Jan 20 2026, 9:24 PM"), + menu = previewMenu(), + ), + statusBanner = TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, + title = stringReference("Refunded in WBTC"), + subtitle = stringReference( + "Your funds have been refunded in WBTC to your wallet on the Polygon network, " + + "in accordance with OKX exchange rules. ", + ) + styledStringReference( + value = "Learn more", + spanStyleReference = { SpanStyle(textDecoration = TextDecoration.Underline) }, + onClick = {}, + ), + isLoading = false, + ), + providerButton = TxHistoryDetailsUM.ProviderButtonUM( + text = stringReference("Go to token"), + onClick = {}, + ), +) + /** Sample header `•••` menu used by the previews. */ private fun previewMenu() = persistentListOf( TxHistoryDetailsUM.MenuItemUM( diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt index 766ffaba83..0b46a31856 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt @@ -35,14 +35,19 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.ds2.loader.TangemLoader import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.plus +import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.styledStringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons @@ -219,7 +224,9 @@ private fun StatusBannerContent(state: StatusBannerUM, modifier: Modifier = Modi } // Retain the last non-null subtitle so the line stays rendered while it fades out (mirrors the retain above). val lastSubtitle = remember { mutableStateOf(null) } - SideEffect { if (state.subtitle != null) lastSubtitle.value = state.subtitle } + SideEffect { + state.subtitle?.let { subtitle -> lastSubtitle.value = subtitle } + } AnimatedVisibility( visible = state.subtitle != null, @@ -232,9 +239,10 @@ private fun StatusBannerContent(state: StatusBannerUM, modifier: Modifier = Modi exit = shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Top) + fadeOut(tween(DEFAULT_ANIMATION_MILLIS)), ) { - (state.subtitle ?: lastSubtitle.value)?.let { subtitle -> + val subtitle = state.subtitle ?: lastSubtitle.value + subtitle?.let { line -> Text( - text = subtitle.resolveReference(), + text = line.resolveAnnotatedReference(), style = TangemTheme.typography3.caption.medium, color = contentColor, modifier = Modifier.padding(top = SUBTITLE_TOP_GAP), @@ -333,6 +341,21 @@ private fun TxHistoryDetailsStatusBannerPreview() { isLoading = false, ), ) + TxHistoryDetailsStatusBanner( + state = StatusBannerUM( + severity = Severity.Error, + title = stringReference("Refunded in WBTC"), + subtitle = stringReference( + "Your funds have been refunded in WBTC to your wallet on the Polygon network, " + + "in accordance with OKX exchange rules. ", + ) + styledStringReference( + value = "Learn more", + spanStyleReference = { SpanStyle(textDecoration = TextDecoration.Underline) }, + onClick = {}, + ), + isLoading = false, + ), + ) } } } diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt index 956576795b..10cf44c34e 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToDetailsUMConverterTest.kt @@ -188,15 +188,29 @@ internal class ExpressTxToDetailsUMConverterTest : TxDetailsConverterTestBase() } @Test - fun `GIVEN failed express swap WHEN convert THEN both legs faded and signs dropped`() { + fun `GIVEN failed express swap WHEN convert THEN from keeps minus unfaded and to is faded unsigned`() { // Act val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)) - // Assert - assertThat(result.from?.isFaded).isTrue() + // Assert — the spent leg stands as sent; only the never-received leg is struck through, with no sign. + assertThat(result.from?.amount?.resolveString()).startsWith("- ") + assertThat(result.from?.isFaded).isFalse() assertThat(result.to?.isFaded).isTrue() - assertThat(result.from?.amount?.resolveString()).doesNotContain("-") assertThat(result.to?.amount?.resolveString()).doesNotContain("+") + assertThat(result.to?.amount?.resolveString()).doesNotContain("~") + } + + @Test + fun `GIVEN refunded express swap WHEN convert THEN from keeps minus unfaded and to is faded unsigned`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Refunded)) + + // Assert + assertThat(result.from?.amount?.resolveString()).startsWith("- ") + assertThat(result.from?.isFaded).isFalse() + assertThat(result.to?.isFaded).isTrue() + assertThat(result.to?.amount?.resolveString()).doesNotContain("+") + assertThat(result.to?.amount?.resolveString()).doesNotContain("~") } @Test diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverterTest.kt index dca56d6ec5..e2e2cbc088 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/OnChainTxToDetailsUMConverterTest.kt @@ -208,6 +208,56 @@ internal class OnChainTxToDetailsUMConverterTest : TxDetailsConverterTestBase() assertThat(amount).doesNotContain("-") } + @ParameterizedTest + @MethodSource("provideUnsignedAmountTypes") + fun `GIVEN protocol-interaction tx WHEN convert THEN amount has no sign regardless of direction`( + type: TransactionType, + ) { + // Arrange — staking (except ClaimRewards) and approvals show the operation amount, not a signed transfer. + val tx = txInfo(type = type, isOutgoing = true) + + // Act + val amount = converter.convert(tx).amountBlock.amount.resolveString() + + // Assert + assertThat(amount).doesNotContain("+") + assertThat(amount).doesNotContain("-") + } + + private fun provideUnsignedAmountTypes() = listOf( + TransactionType.Staking.Stake, + TransactionType.Staking.Unstake, + TransactionType.Staking.Restake, + TransactionType.Staking.Withdraw, + TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS), + TransactionType.Approve, + ) + + @Test + fun `GIVEN ClaimRewards WHEN convert THEN amount has plus sign regardless of direction`() { + // Arrange — rewards are an inflow even when the chain reports the claiming tx as outgoing. + val tx = txInfo(type = TransactionType.Staking.ClaimRewards, isOutgoing = true) + + // Act + val amount = converter.convert(tx).amountBlock.amount.resolveString() + + // Assert + assertThat(amount).startsWith("+ ") + } + + @Test + fun `GIVEN failed ClaimRewards WHEN convert THEN sign is dropped`() { + // Arrange + val tx = txInfo(type = TransactionType.Staking.ClaimRewards, status = TxInfo.TransactionStatus.Failed) + + // Act + val amountBlock = converter.convert(tx).amountBlock + + // Assert + assertThat(amountBlock.isFailed).isTrue() + assertThat(amountBlock.amount.resolveString()).doesNotContain("+") + } + @Test fun `GIVEN non-yield Transfer WHEN convert THEN single icon and no label`() { // Arrange diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index b97747a106..2e38809c1a 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -1,13 +1,16 @@ package com.tangem.features.txhistory.converter import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +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_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.domain.express.models.ExpressExchangeStatus import com.tangem.domain.express.models.ExpressOnrampStatus +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.TxHistoryInfo @@ -168,6 +171,93 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest : TxDetailsConvert // endregion + // region Refunded express swap + + @Test + fun `GIVEN refunded express swap with resolved refund token WHEN convert THEN refunded-in banner with link`() { + // Arrange + var learnMoreClicked = false + val refundConverter = refundConverter(onLearnMore = { learnMoreClicked = true }) + + // Act + val result = refundConverter.convert(expressSwap(status = ExpressExchangeStatus.Refunded)) + as TxHistoryDetailsUM.TwoAssets + + // Assert — the subtitle's trailing "Learn more" is a styled reference carrying a lambda, so the subtitle is + // nulled out for the whole-object comparison and its parts are checked apart. + val banner = requireNotNull(result.statusBanner) + assertThat(banner.copy(subtitle = null)).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, + title = resourceReference( + id = R.string.express_exchange_notification_refunded_in_title, + formatArgs = wrappedList(bitcoin.symbol), + ), + isLoading = false, + ), + ) + assertThat(banner.subtitle).isInstanceOf(TextReference.Combined::class.java) + val subtitle = banner.subtitle as TextReference.Combined + assertThat(subtitle.refs.data.first()).isEqualTo( + resourceReference( + id = R.string.express_exchange_notification_refunded_in_text, + formatArgs = wrappedList(bitcoin.symbol, bitcoin.network.name), + ), + ) + val link = subtitle.refs.data.last() as TextReference.StyledRes + assertThat(link.id).isEqualTo(R.string.common_learn_more) + link.onClick?.invoke() + assertThat(learnMoreClicked).isTrue() + } + + @Test + fun `GIVEN refunded express swap with resolved refund token WHEN convert THEN go-to-token button with the token`() { + // Arrange + val goToTokenClicks = mutableListOf() + val refundConverter = refundConverter(onGoToToken = goToTokenClicks::add) + + // Act + val result = refundConverter.convert(expressSwap(status = ExpressExchangeStatus.Refunded)) + as TxHistoryDetailsUM.TwoAssets + + // Assert + val button = result.providerButton + assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_token)) + button?.onClick?.invoke() + assertThat(goToTokenClicks).containsExactly(bitcoin) + } + + @Test + fun `GIVEN refunded express swap without refund token WHEN convert THEN fallback error banner and no button`() { + // Act — the refund token is unresolved (e.g. offline / not a bridge deal), even though a provider url exists. + val result = dispatcher().convert( + expressSwap(status = ExpressExchangeStatus.Refunded, externalTxUrl = EXTERNAL_URL), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.statusBanner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, + title = resourceReference(R.string.express_exchange_status_refunded), + isLoading = false, + ), + ) + assertThat(result.providerButton).isNull() + } + + @Test + fun `GIVEN finished express swap with resolved refund token WHEN convert THEN refund banner not applied`() { + // Act — a stale refund resolution must not leak into non-refunded terminals. + val result = refundConverter().convert(expressSwap(status = ExpressExchangeStatus.Finished)) + as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.statusBanner?.severity).isEqualTo(TxHistoryDetailsUM.StatusBannerUM.Severity.Success) + assertThat(result.providerButton).isNull() + } + + // endregion + private fun dispatcher( onCopyTxId: (() -> Unit)? = null, onShare: (() -> Unit)? = null, @@ -182,4 +272,17 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest : TxDetailsConvert onExplore = onExplore, lookup = lookup, ) + + /** Converter with a resolved refund token (bitcoin) and the refund callbacks wired. */ + private fun refundConverter( + onLearnMore: () -> Unit = {}, + onGoToToken: (CryptoCurrency) -> Unit = {}, + ) = TxHistoryInfoToTxHistoryDetailsUMConverter( + currency = currency, + onCopyAddress = copiedAddresses::add, + onGoToProvider = openedUrls::add, + refundCurrency = bitcoin, + onLearnMoreAboutRefundsClick = onLearnMore, + onGoToRefundedTokenClick = onGoToToken, + ) } \ No newline at end of file