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 aec7dd1d42..047dd80f26 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 @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext @@ -32,6 +33,8 @@ import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.component.TxHistoryDetailsComponent +import com.tangem.features.txhistory.component.TxHistoryDetailsSlotConfig import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent import dagger.assisted.Assisted @@ -44,6 +47,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( @Assisted params: TokenDetailsComponent.Params, tokenMarketBlockComponentFactory: TokenMarketBlockComponent.Factory, txHistoryComponentFactory: TxHistoryComponent.Factory, + private val txHistoryDetailsComponentFactory: TxHistoryDetailsComponent.Factory, expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val yieldSupplyWarningComponentFactory: YieldSupplyDepositedWarningComponent.Factory, @@ -59,6 +63,9 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( userWalletId = params.userWalletId, currency = params.currency, openExplorer = model::onExploreClick, + onTxDetailsRequested = { txHistoryInfo -> + model.txDetailsNavigation.activate(TxHistoryDetailsSlotConfig(txHistoryInfo)) + }, ), ) @@ -88,6 +95,24 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( }, ) + private val txHistoryDetailsSlot = childSlot( + key = TX_HISTORY_DETAILS_SLOT_KEY, + source = model.txDetailsNavigation, + serializer = null, + handleBackButton = true, + childFactory = { config, ctx -> + txHistoryDetailsComponentFactory.create( + context = childByContext(ctx), + params = TxHistoryDetailsComponent.Params( + txHistoryInfo = config.txHistoryInfo, + userWalletId = params.userWalletId, + currency = params.currency, + onDismiss = model.txDetailsNavigation::dismiss, + ), + ) + }, + ) + private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams -> tokenMarketBlockComponentFactory.create( appComponentContext = child("tokenMarketBlockComponent"), @@ -109,6 +134,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val bottomSheet by bottomSheetSlot.subscribeAsState() val ratingSlotState by ratingSlot.subscribeAsState() + val txHistoryDetails by txHistoryDetailsSlot.subscribeAsState() NavigationBar3ButtonsScrim() if (LocalRedesignEnabled.current) { @@ -136,6 +162,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( } bottomSheet.child?.instance?.BottomSheet() + txHistoryDetails.child?.instance?.BottomSheet() } private fun CryptoCurrency.toTokenMarketParam(): TokenMarketBlockComponent.Params? { @@ -204,5 +231,6 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( companion object { private const val RATING_SLOT_KEY = "ratingSlot" + private const val TX_HISTORY_DETAILS_SLOT_KEY = "txHistoryDetailsSlot" } } \ No newline at end of file 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 df605c6c7e..a63ccf6da6 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 @@ -128,6 +128,7 @@ import com.tangem.features.tokendetails.ExpressTransactionsEvent import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R +import com.tangem.features.txhistory.component.TxHistoryDetailsSlotConfig import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics @@ -232,6 +233,7 @@ internal class TokenDetailsModel @Inject constructor( val bottomSheetNavigation: SlotNavigation = SlotNavigation() val ratingSlotNavigation = SlotNavigation() + val txDetailsNavigation = SlotNavigation() private val stateFactory = TokenDetailsStateFactory( currentStateProvider = Provider { uiState.value }, diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt index 581f4ac2e2..ea9d747f7b 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt @@ -6,8 +6,10 @@ import androidx.compose.runtime.Stable import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.features.txhistory.entity.TxHistoryItemsUM import com.tangem.features.txhistory.entity.TxHistoryUM +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @Stable @@ -25,6 +27,7 @@ interface TxHistoryComponent { val userWalletId: UserWalletId, val currency: CryptoCurrency, val openExplorer: () -> Unit, + val onTxDetailsRequested: (Flow) -> Unit, ) interface Factory : ComponentFactory 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 eafab54206..add3fd0778 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 @@ -3,14 +3,14 @@ package com.tangem.features.txhistory.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.txhistory.model.TxHistoryInfo import kotlinx.coroutines.flow.Flow interface TxHistoryDetailsComponent : ComposableBottomSheetComponent { data class Params( - val txInfo: Flow, + val txHistoryInfo: Flow, val userWalletId: UserWalletId, val currency: CryptoCurrency, val onDismiss: () -> Unit, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt index 46ade74b07..6cec8475d1 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt @@ -12,10 +12,9 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.toTimeFormat -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.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.explorerHash import com.tangem.features.txhistory.impl.R import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.utils.StringsSigns @@ -30,8 +29,8 @@ import java.math.BigDecimal * express statuses collapse into the three [Status] buckets (those drive title/icon/amount colors in the row UI). * * The counterparty ticker symbol+icon come from the resolved [ExpressTransactionAsset.cryptoCurrency] (swap); - * onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click opens the - * explorer. + * onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click routes through + * [TxHistoryUiActions.onTransactionClick] (express rows open the in-app details sheet). */ internal class ExpressTxToTransactionItemUMConverter( private val currency: CryptoCurrency, @@ -39,6 +38,8 @@ internal class ExpressTxToTransactionItemUMConverter( ) : Converter { private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter() + private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter() override fun convert(value: ExpressTx): TransactionItemUM = when (value) { is ExpressTx.Swap -> swapContent(value) @@ -46,7 +47,7 @@ internal class ExpressTxToTransactionItemUMConverter( } private fun swapContent(swap: ExpressTx.Swap): TransactionItemUM.Content { - val status = swap.tx.status.toUiStatus() + val status = exchangeStatusConverter.convert(swap.tx.status) val viewedAmount = if (swap.isOutgoing) swap.tx.fromAsset.amount else swap.tx.toAsset.amount val counterparty = if (swap.isOutgoing) swap.tx.toAsset else swap.tx.fromAsset val prefix = when { @@ -72,7 +73,7 @@ internal class ExpressTxToTransactionItemUMConverter( } private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content { - val status = onramp.tx.status.toUiStatus() + val status = onrampStatusConverter.convert(onramp.tx.status) val prefix = when { status is Status.Failed -> "" status is Status.Confirmed -> StringsSigns.PLUS @@ -107,15 +108,15 @@ internal class ExpressTxToTransactionItemUMConverter( subtitle: ContentSubtitle, warning: TextReference?, ): TransactionItemUM.Content { - val explorerHash = tx.matchHash ?: tx.txId + // Row identity (Compose key): the on-chain leg's hash when matched, else the express txId stands in. return TransactionItemUM.Content( - txHash = explorerHash, + txHash = tx.explorerHash ?: tx.txId, amount = amount, currencySymbol = currency.symbol, time = tx.timestampMillis.toTimeFormat(), status = status, direction = direction, - onClick = { txHistoryUiActions.openTxInExplorer(explorerHash) }, + onClick = { txHistoryUiActions.onTransactionClick(tx) }, iconRes = iconRes, title = title, subtitle = subtitle, @@ -142,50 +143,4 @@ internal class ExpressTxToTransactionItemUMConverter( wrappedList(resourceReference(R.string.tx_history_onramp_top_up)), ) } -} - -// region Status mapping - -/** - * Collapses the typed swap status into a UI [Status] bucket: the single success state ([Finished][Confirmed]), - * the failure/return states ([Failed]/[TxFailed]/[Refunded]/[Expired]/[Unknown]) → Failed, everything in flight - * (incl. [Verifying] and [Paused]) → Unconfirmed. - */ -private fun ExpressExchangeStatus.toUiStatus(): Status = when (this) { - ExpressExchangeStatus.Finished -> Status.Confirmed - ExpressExchangeStatus.Failed, - ExpressExchangeStatus.TxFailed, - ExpressExchangeStatus.Refunded, - ExpressExchangeStatus.Expired, - ExpressExchangeStatus.Unknown, - -> Status.Failed - ExpressExchangeStatus.Preview, - ExpressExchangeStatus.Created, - ExpressExchangeStatus.ExchangeTxSent, - ExpressExchangeStatus.Waiting, - ExpressExchangeStatus.WaitingTxHash, - ExpressExchangeStatus.Confirming, - ExpressExchangeStatus.Exchanging, - ExpressExchangeStatus.Sending, - ExpressExchangeStatus.Verifying, - ExpressExchangeStatus.Paused, - -> Status.Unconfirmed -} - -private fun ExpressOnrampStatus.toUiStatus(): Status = when (this) { - ExpressOnrampStatus.Finished -> Status.Confirmed - ExpressOnrampStatus.Failed, - ExpressOnrampStatus.Expired, - ExpressOnrampStatus.Unknown, - -> Status.Failed - ExpressOnrampStatus.Created, - ExpressOnrampStatus.WaitingForPayment, - ExpressOnrampStatus.PaymentProcessing, - ExpressOnrampStatus.Verifying, - ExpressOnrampStatus.Paid, - ExpressOnrampStatus.Sending, - ExpressOnrampStatus.Paused, - -> Status.Unconfirmed -} - -// endregion \ No newline at end of file +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt index a52b20b829..830bc48f9b 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt @@ -4,15 +4,21 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.utils.converter.Converter /** - * Converts a merged [TxHistoryInfo] row to [TransactionItemUM]: on-chain rows convert their `TxInfo` via - * [TxHistoryItemToTransactionItemUMConverter]; express rows map directly via [ExpressTxToTransactionItemUMConverter]. + * Converts a merged [TxHistoryInfo] row to [TransactionItemUM]. + * + * On-chain rows render via the plain [TxHistoryItemToTransactionItemUMConverter] (which knows only `TxInfo`); since + * that converter cannot reference the merged row, the row click is bound here to the **incoming** [OnChainTx] so the + * model resolves it in the live list by [TxHistoryInfo.txId]. Express rows carry their own [TxHistoryInfo] and wire + * the click themselves in [ExpressTxToTransactionItemUMConverter]. */ internal class TxHistoryInfoToTransactionItemUMConverter( private val txInfoConverter: TxHistoryItemToTransactionItemUMConverter, private val expressConverter: ExpressTxToTransactionItemUMConverter, + private val txHistoryUiActions: TxHistoryUiActions, ) : Converter { override fun convert(value: TxHistoryInfo): TransactionItemUM = when (value) { @@ -21,6 +27,10 @@ internal class TxHistoryInfoToTransactionItemUMConverter( } private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) { - is OnChainTx.BSDK -> txInfoConverter.convert(value.txInfo) + is OnChainTx.BSDK -> when (val um = txInfoConverter.convert(value.txInfo)) { + // Content rows (transfer/swap/…) route through the details/explorer decision; pills stay on the explorer. + is TransactionItemUM.Content -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) }) + else -> um + } } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt deleted file mode 100644 index 0afece079a..0000000000 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.tangem.features.txhistory.converter - -import androidx.annotation.StringRes -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.core.ui.components.transactions.state.TransactionItemUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.utils.DateTimeFormatters -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.models.network.TxInfo.TransactionType -import com.tangem.features.txhistory.entity.TxHistoryDetailsUM -import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity -import com.tangem.features.txhistory.impl.R -import com.tangem.utils.StringsSigns -import com.tangem.utils.converter.Converter -import com.tangem.utils.extensions.isZero -import com.tangem.utils.toBriefAddressFormat -import kotlinx.collections.immutable.persistentListOf -import org.joda.time.DateTime - -/** - * Converts a [TxInfo] to a [TxHistoryDetailsUM] for the in-app transaction details card. - * - * Single dispatch on [TxInfo.type] picks the layout family — mirroring the same `when(type)` used by - * [TxHistoryItemToTransactionItemUMConverter]: - * - [TransactionType.Swap] (and onramp once it lands in `TxInfo`) -> [TxHistoryDetailsUM.TwoAssets] - * - everything else -> [TxHistoryDetailsUM.SingleAsset] - */ -internal class TxInfoToTxHistoryDetailsUMConverter( - private val currency: CryptoCurrency, - private val onCopyAddress: (String) -> Unit, -) : Converter { - - private val iconStateConverter = CryptoCurrencyToIconStateConverter() - - override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) { - // TODO([REDACTED_TASK_KEY]): populate `from` / `to` legs once TxInfo exposes the swap legs (amounts, currencies, fiat). - // Until then the card falls back to the header-only placeholder (the TwoAssetsBlock UI is already wired). - is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets( - header = value.toHeaderUM(), - statusBanner = value.toStatusBannerUM(), - ) - else -> TxHistoryDetailsUM.SingleAsset( - header = value.toHeaderUM(), - amountBlock = value.toAmountBlockUM(), - counterparty = value.toCounterpartyUM(), - // TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo. - rows = persistentListOf(), - ) - } - - private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( - iconRes = headerIcon(), - status = status.toUiStatus(), - title = headerTitle(), - subtitle = headerSubtitle(), - ) - - /** - * Express status plaque under the swap block. A stopgap over the three generic [TxInfo.TransactionStatus] values — - * so [Severity.Warning] (verification) is not reachable yet. - * - * [REDACTED_TODO_COMMENT] - */ - private fun TxInfo.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (status) { - is TxInfo.TransactionStatus.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Info, - title = resourceReference(R.string.express_exchange_status_receiving_active), - isLoading = true, - ) - is TxInfo.TransactionStatus.Confirmed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Success, - title = resourceReference(R.string.express_exchange_status_exchanged), - isLoading = false, - ) - is TxInfo.TransactionStatus.Failed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Error, - title = resourceReference(R.string.express_exchange_status_failed), - subtitle = resourceReference(R.string.express_exchange_notification_failed_text), - isLoading = false, - ) - } - - private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( - currencyIcon = iconStateConverter.convert(currency), - amount = stringReference(signedAmount(currency)), - // TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded - // placeholder would show a misleading value. - fiatAmount = TextReference.EMPTY, - isFailed = status is TxInfo.TransactionStatus.Failed, - ) - - /** - * Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced — built from - * the `User` interaction address (the same source the history list uses for its external-address subtitle). - * - * The own-account / own-wallet avatars ([TxHistoryDetailsUM.CounterpartyAvatar.Account] / `Wallet`) require the - * address->owner lookup the list assembles in `TxHistoryLookupContext`; wiring that into the detail model is a - * follow-up, so for now a counterparty that is not a plain external `User` address yields no card (`null`). - */ - private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? { - val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null - return TxHistoryDetailsUM.CounterpartyUM( - label = counterpartyLabel(), - title = stringReference(address.toBriefAddressFormat()), - avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address), - onCopyClick = { onCopyAddress(address) }, - ) - } - - /** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */ - private fun TxInfo.counterpartyLabel(): TextReference = - if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from) -} - -// region Amount building helpers - -/** - * Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+` - * otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) — the UI then only - * strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed]. - */ -private fun TxInfo.signedAmount(currency: CryptoCurrency): String { - val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) } - val prefix = when { - status is TxInfo.TransactionStatus.Failed -> "" - amount.isZero() -> "" - isOutgoing -> "${StringsSigns.MINUS} " - else -> "${StringsSigns.PLUS} " - } - return (prefix + formatted).trim() -} - -// endregion - -// region Header building helpers - -/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */ -private fun TxInfo.headerIcon(): Int = when (type) { - is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24 - else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 -} - -private fun TxInfo.headerTitle(): TextReference = when (type) { - is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped) - is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred) - else -> stringReference(type.toString()) -} - -private fun TxInfo.headerSubtitle(): TextReference { - val dateTime = DateTime(timestampInMillis) - val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime) - val time = DateTimeFormatters.timeFormatter.print(dateTime) - return stringReference("$date, $time") -} - -private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) { - is TxInfo.TransactionStatus.Failed -> - resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) - is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending) - is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed) -} - -private fun TxInfo.TransactionStatus.toUiStatus(): TransactionItemUM.Content.Status = when (this) { - TxInfo.TransactionStatus.Confirmed -> TransactionItemUM.Content.Status.Confirmed - TxInfo.TransactionStatus.Failed -> TransactionItemUM.Content.Status.Failed - TxInfo.TransactionStatus.Unconfirmed -> TransactionItemUM.Content.Status.Unconfirmed -} - -// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt index b6f45b5318..59c3845cb1 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 @@ -6,7 +6,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.features.txhistory.component.TxHistoryDetailsComponent -import com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter +import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.SharingStarted @@ -26,12 +26,12 @@ internal class TxHistoryDetailsModel @Inject constructor( private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() - private val converter = TxInfoToTxHistoryDetailsUMConverter( + private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( currency = params.currency, onCopyAddress = ::onCopyAddress, ) - val uiState: StateFlow = params.txInfo + val uiState: StateFlow = params.txHistoryInfo .map(converter::convert) .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index 9826f149c0..6193084ec0 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -20,7 +20,11 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.domain.txhistory.model.explorerHash import com.tangem.domain.txhistory.models.TxHistoryStateError import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase @@ -211,6 +215,7 @@ internal class TxHistoryModel @Inject constructor( currency = params.currency, txHistoryUiActions = this, ), + txHistoryUiActions = this, ) val items = mutableListOf() @@ -361,4 +366,20 @@ internal class TxHistoryModel @Inject constructor( ifRight = { urlOpener.openUrl(url = it) }, ) } + + override fun onTransactionClick(item: TxHistoryInfo) { + // manager is non-null only under the new tx-history toggle; on the legacy path every tap falls to the explorer. + val manager = historyTxListManager + if (manager != null && item.opensInAppDetails()) { + params.onTxDetailsRequested(manager.txHistoryInfoFlow(item)) + } else { + item.explorerHash?.let(::openTxInExplorer) + } + } +} + +/** On-chain transfers/swaps and every express op open the in-app details sheet; everything else goes to the explorer. */ +private fun TxHistoryInfo.opensInAppDetails(): Boolean = when (this) { + is ExpressTx -> true + is OnChainTx.BSDK -> txInfo.type is TxInfo.TransactionType.Transfer || txInfo.type is TxInfo.TransactionType.Swap } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt index 06a014161f..748d8b5c2f 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt @@ -120,16 +120,6 @@ internal class TxHistoryListManager( ) } - fun txInfoFlow(txHash: String, type: TxInfo.TransactionType): Flow = state - .map { st -> - st.rawBatches.asSequence() - .flatMap { it.data.items } - .firstOrNull { it.txHash == txHash && it.type == type } - } - .filterNotNull() - .distinctUntilChanged() - .flowOn(dispatchers.default) - private fun updateState( batchListState: BatchListState>, lookupContext: TxHistoryLookupContext?, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt index 57a50a9014..bc04053286 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt @@ -1,7 +1,10 @@ package com.tangem.features.txhistory.utils +import com.tangem.domain.txhistory.model.TxHistoryInfo + internal interface TxHistoryUiActions { fun openExplorer() fun openTxInExplorer(txHash: String) + fun onTransactionClick(item: TxHistoryInfo) } \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt index ee4a06fb0f..8719768595 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt @@ -196,21 +196,19 @@ internal class ExpressTxToTransactionItemUMConverterTest { } @Test - fun `GIVEN matched on-chain leg WHEN row clicked THEN opens explorer by match hash`() { - val result = converter.convert( - createSwap(status = ExpressExchangeStatus.Waiting, matchHash = "0xhash", isOutgoing = true), - ) as TransactionItemUM.Content + fun `GIVEN express row WHEN row clicked THEN opens in-app details for that tx`() { + val swap = createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true) + val result = converter.convert(swap) as TransactionItemUM.Content result.onClick() - verify { txHistoryUiActions.openTxInExplorer("0xhash") } + verify { txHistoryUiActions.onTransactionClick(swap) } } // endregion private fun createSwap( status: ExpressExchangeStatus, - matchHash: String? = null, isOutgoing: Boolean = true, fromAmount: BigDecimal? = BigDecimal("1.5"), toAmount: BigDecimal? = BigDecimal("0.001"), @@ -220,8 +218,8 @@ internal class ExpressTxToTransactionItemUMConverterTest { status = status, createdAtMillis = 100, provider = null, - payinHash = matchHash.takeIf { isOutgoing }, - payoutHash = matchHash.takeUnless { isOutgoing }, + payinHash = null, + payoutHash = null, fromAsset = ExpressTransactionAsset( id = ExpressAssetId(networkId = "eth", contractAddress = "0"), amount = fromAmount, diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt new file mode 100644 index 0000000000..f1b8c4915e --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt @@ -0,0 +1,131 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressTransactionAsset +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.features.txhistory.utils.TxHistoryUiActions +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TxHistoryInfoToTransactionItemUMConverterTest { + + private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true) + private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum + + private val converter = TxHistoryInfoToTransactionItemUMConverter( + txInfoConverter = TxHistoryItemToTransactionItemUMConverter( + currency = currency, + txHistoryUiActions = txHistoryUiActions, + ), + expressConverter = ExpressTxToTransactionItemUMConverter( + currency = currency, + txHistoryUiActions = txHistoryUiActions, + ), + txHistoryUiActions = txHistoryUiActions, + ) + + @Test + fun `GIVEN on-chain content row WHEN row clicked THEN routes the incoming OnChainTx through onTransactionClick`() { + // Arrange + val item = OnChainTx.BSDK(txInfo(type = TransactionType.Transfer)) + + // Act + val result = converter.convert(item) as TransactionItemUM.Content + result.onClick() + + // Assert + verify { txHistoryUiActions.onTransactionClick(item) } + } + + @Test + fun `GIVEN on-chain swap row WHEN row clicked THEN routes the incoming OnChainTx through onTransactionClick`() { + // Arrange + val item = OnChainTx.BSDK(txInfo(type = TransactionType.Swap)) + + // Act + val result = converter.convert(item) as TransactionItemUM.Content + result.onClick() + + // Assert + verify { txHistoryUiActions.onTransactionClick(item) } + } + + @Test + fun `GIVEN on-chain pill row WHEN row clicked THEN stays on the explorer`() { + // Arrange + val item = OnChainTx.BSDK(txInfo(type = TransactionType.Approve)) + + // Act + val result = converter.convert(item) as TransactionItemUM.Pill + result.onClick() + + // Assert + verify { txHistoryUiActions.openTxInExplorer(TX_HASH) } + } + + @Test + fun `GIVEN express row WHEN row clicked THEN routes the incoming ExpressTx through onTransactionClick`() { + // Arrange + val item = expressSwap() + + // Act + val result = converter.convert(item) as TransactionItemUM.Content + result.onClick() + + // Assert + verify { txHistoryUiActions.onTransactionClick(item) } + } + + private fun txInfo(type: TransactionType): TxInfo = TxInfo( + txHash = TX_HASH, + timestampInMillis = TIMESTAMP, + isOutgoing = false, + destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), + sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + status = TxInfo.TransactionStatus.Confirmed, + type = type, + amount = BigDecimal.ONE, + ) + + private fun expressSwap(): ExpressTx.Swap = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "swap-1", + status = ExpressExchangeStatus.Exchanging, + createdAtMillis = TIMESTAMP, + provider = null, + payinHash = null, + payoutHash = null, + fromAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "ethereum", contractAddress = "0"), + amount = BigDecimal("1.5"), + decimals = 18, + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "bitcoin", contractAddress = "0"), + amount = BigDecimal("0.001"), + decimals = 8, + ), + ), + isOutgoing = true, + txInfo = null, + ) + + private companion object { + const val TX_HASH = "0xtxhash" + const val TIMESTAMP = 1_700_000_000_000L + const val USER_ADDRESS = "0x1234567890abcdef1234" + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt similarity index 57% rename from features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt rename to features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index 2ddf7a9b3a..a8330706ac 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -6,10 +6,21 @@ import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.domain.express.models.ExpressTransactionAsset +import com.tangem.domain.express.models.OnrampTransaction import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.domain.tokens.model.Amount +import com.tangem.domain.tokens.model.AmountType +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.features.txhistory.impl.R +import com.tangem.test.core.ProvideTestModels import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkStatic @@ -17,14 +28,15 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class TxInfoToTxHistoryDetailsUMConverterTest { +internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { private val currency = MockCryptoCurrencyFactory().ethereum private val copiedAddresses = mutableListOf() - private val converter = TxInfoToTxHistoryDetailsUMConverter( + private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( currency = currency, onCopyAddress = copiedAddresses::add, ) @@ -42,44 +54,49 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { unmockkStatic(DateFormat::class) } + // region On-chain (TxInfo) + @Test - fun `GIVEN Swap WHEN convert THEN TwoAssets`() { + fun `GIVEN on-chain Swap WHEN convert THEN SingleAsset fallback`() { + // A two-asset swap always surfaces as ExpressTx.Swap; an on-chain TxInfo of type Swap has no legs, + // so it falls back to the single amount it does carry rather than an empty two-asset card. // Arrange - val tx = txInfo(type = TransactionType.Swap) + val tx = onChain(type = TransactionType.Swap) // Act val result = converter.convert(tx) // Assert - assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java) + assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java) } - @Test - fun `GIVEN non-Swap TransactionType WHEN convert THEN SingleAsset`() { - val nonSwapTypes = listOf( - TransactionType.Transfer, - TransactionType.Approve, - TransactionType.Operation(name = "Mint NFT"), - TransactionType.UnknownOperation, - TransactionType.GaslessFee, - TransactionType.Staking.Stake, - TransactionType.Staking.ClaimRewards, - TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS), - TransactionType.YieldSupply.Topup, - TransactionType.YieldSupply.Enter(address = USER_ADDRESS), - ) + @ParameterizedTest + @ProvideTestModels + fun `GIVEN non-Swap TransactionType WHEN convert THEN SingleAsset`(type: TransactionType) { + // Act + val result = converter.convert(onChain(type = type)) - nonSwapTypes.forEach { type -> - val result = converter.convert(txInfo(type = type)) - - assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java) - } + // Assert + assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java) } + private fun provideTestModels() = listOf( + TransactionType.Transfer, + TransactionType.Approve, + TransactionType.Operation(name = "Mint NFT"), + TransactionType.UnknownOperation, + TransactionType.GaslessFee, + TransactionType.Staking.Stake, + TransactionType.Staking.ClaimRewards, + TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS), + TransactionType.YieldSupply.Topup, + TransactionType.YieldSupply.Enter(address = USER_ADDRESS), + ) + @Test fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer) + val tx = onChain(type = TransactionType.Transfer) // Act val header = converter.convert(tx).header @@ -93,7 +110,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN Swap WHEN convert THEN header has exchange icon`() { // Arrange - val tx = txInfo(type = TransactionType.Swap) + val tx = onChain(type = TransactionType.Swap) // Act val header = converter.convert(tx).header @@ -102,65 +119,10 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24) } - @Test - fun `GIVEN unconfirmed Swap WHEN convert THEN info status banner with loader`() { - // Arrange - val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Unconfirmed) - - // Act - val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner - - // Assert - assertThat(banner).isEqualTo( - TxHistoryDetailsUM.StatusBannerUM( - severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info, - title = resourceReference(R.string.express_exchange_status_receiving_active), - isLoading = true, - ), - ) - } - - @Test - fun `GIVEN confirmed Swap WHEN convert THEN success status banner without loader`() { - // Arrange - val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Confirmed) - - // Act - val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner - - // Assert - assertThat(banner).isEqualTo( - TxHistoryDetailsUM.StatusBannerUM( - severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success, - title = resourceReference(R.string.express_exchange_status_exchanged), - isLoading = false, - ), - ) - } - - @Test - fun `GIVEN failed Swap WHEN convert THEN error status banner with refund subtitle`() { - // Arrange - val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Failed) - - // Act - val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner - - // Assert - assertThat(banner).isEqualTo( - TxHistoryDetailsUM.StatusBannerUM( - severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, - title = resourceReference(R.string.express_exchange_status_failed), - subtitle = resourceReference(R.string.express_exchange_notification_failed_text), - isLoading = false, - ), - ) - } - @Test fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer, isOutgoing = false) + val tx = onChain(type = TransactionType.Transfer, isOutgoing = false) // Act val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock @@ -173,7 +135,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN outgoing Transfer WHEN convert THEN amount block has minus sign`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true) + val tx = onChain(type = TransactionType.Transfer, isOutgoing = true) // Act val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock @@ -185,7 +147,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN zero amount WHEN convert THEN amount block has no sign`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO) + val tx = onChain(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO) // Act val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock @@ -199,7 +161,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN failed outgoing Transfer WHEN convert THEN amount block is failed and drops the sign`() { // Arrange - val tx = txInfo( + val tx = onChain( type = TransactionType.Transfer, isOutgoing = true, status = TxInfo.TransactionStatus.Failed, @@ -218,7 +180,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN no interaction address WHEN convert THEN counterparty is null`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer, interactionAddressType = null) + val tx = onChain(type = TransactionType.Transfer, interactionAddressType = null) // Act val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty @@ -230,7 +192,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN incoming Transfer with User address WHEN convert THEN address-avatar counterparty with From label`() { // Arrange - val tx = txInfo( + val tx = onChain( type = TransactionType.Transfer, isOutgoing = false, interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), @@ -247,7 +209,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN outgoing Transfer with User address WHEN convert THEN counterparty has Recipient label`() { // Arrange - val tx = txInfo( + val tx = onChain( type = TransactionType.Transfer, isOutgoing = true, interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), @@ -263,7 +225,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN address counterparty WHEN onCopyClick invoked THEN raw address is copied`() { // Arrange - val tx = txInfo( + val tx = onChain( type = TransactionType.Transfer, interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), ) @@ -276,24 +238,131 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { assertThat(copiedAddresses).containsExactly(USER_ADDRESS) } - private fun txInfo( + // endregion + + // region Express (swap / onramp) + + @Test + fun `GIVEN express swap WHEN convert THEN TwoAssets with exchange icon`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) + + // Assert + assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java) + assertThat(result.header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24) + } + + @Test + fun `GIVEN in-progress express swap WHEN convert THEN info status banner with loader`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) + val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info, + title = resourceReference(R.string.express_exchange_status_receiving_active), + isLoading = true, + ), + ) + } + + @Test + fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)) + val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, + title = resourceReference(R.string.express_exchange_status_failed), + subtitle = resourceReference(R.string.express_exchange_notification_failed_text), + isLoading = false, + ), + ) + } + + @Test + fun `GIVEN finished express onramp WHEN convert THEN TwoAssets with success banner`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.header.iconRes).isEqualTo(R.drawable.ic_tangem_card_24) + assertThat(result.statusBanner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success, + title = resourceReference(R.string.express_exchange_status_exchanged), + isLoading = false, + ), + ) + } + + // endregion + + private fun onChain( type: TransactionType, isOutgoing: Boolean = false, status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed, amount: BigDecimal = BigDecimal.ONE, interactionAddressType: TxInfo.InteractionAddressType? = null, - ): TxInfo = TxInfo( - txHash = TX_HASH, - timestampInMillis = TIMESTAMP, - isOutgoing = isOutgoing, - destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), - sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), - interactionAddressType = interactionAddressType, - status = status, - type = type, - amount = amount, + ): OnChainTx.BSDK = OnChainTx.BSDK( + TxInfo( + txHash = TX_HASH, + timestampInMillis = TIMESTAMP, + isOutgoing = isOutgoing, + destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), + sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), + interactionAddressType = interactionAddressType, + status = status, + type = type, + amount = amount, + ), ) + private fun expressSwap(status: ExpressExchangeStatus): ExpressTx.Swap = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "swap-1", + status = status, + createdAtMillis = TIMESTAMP, + provider = null, + payinHash = null, + payoutHash = null, + fromAsset = expressAsset(networkId = "ethereum", amount = BigDecimal("1.5"), decimals = 18), + toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.001"), decimals = 8), + ), + isOutgoing = true, + txInfo = null, + ) + + private fun expressOnramp(status: ExpressOnrampStatus): ExpressTx.Onramp = ExpressTx.Onramp( + tx = OnrampTransaction( + txId = "onramp-1", + status = status, + createdAtMillis = TIMESTAMP, + provider = null, + payoutHash = null, + fromFiat = Amount( + currencySymbol = "SEK", + value = BigDecimal("100"), + decimals = 2, + type = AmountType.FiatType(code = "SEK"), + ), + toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.006"), decimals = 8), + ), + txInfo = null, + ) + + private fun expressAsset(networkId: String, amount: BigDecimal, decimals: Int): ExpressTransactionAsset = + ExpressTransactionAsset( + id = ExpressAssetId(networkId = networkId, contractAddress = "0"), + amount = amount, + decimals = decimals, + ) + private fun TextReference.resolveString(): String = (this as TextReference.Str).value private companion object {