Updated on 2026-08-14
This commit is contained in:
parent
93bab15d0c
commit
e1c58bed0e
15 changed files with 467 additions and 32 deletions
|
|
@ -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)
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ dependencies {
|
|||
|
||||
/** Common */
|
||||
api(projects.common.ui)
|
||||
implementation(projects.common)
|
||||
|
||||
/** Features api */
|
||||
api(projects.features.txhistory.api)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue