Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 12:47:07 +03:00
parent 93bab15d0c
commit e1c58bed0e
15 changed files with 467 additions and 32 deletions

View file

@ -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)
},
),
)
},

View file

@ -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()

View file

@ -14,6 +14,7 @@ interface TxHistoryDetailsComponent : ComposableBottomSheetComponent {
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val onDismiss: () -> Unit,
val onOpenTokenDetails: (CryptoCurrency) -> Unit,
)
interface Factory : ComponentFactory<Params, TxHistoryDetailsComponent>

View file

@ -27,6 +27,7 @@ dependencies {
/** Common */
api(projects.common.ui)
implementation(projects.common)
/** Features api */
api(projects.features.txhistory.api)

View file

@ -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<TxHistoryDetailsUM.MenuItemUM>,
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),

View file

@ -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()
}

View file

@ -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) {

View file

@ -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(

View file

@ -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<CryptoCurrency?>(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<TxHistoryDetailsUM?> = 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
}

View file

@ -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(

View file

@ -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<TextReference?>(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,
),
)
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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<CryptoCurrency>()
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,
)
}