diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 8277d57104..91a906d6b8 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -2051,6 +2051,8 @@ Tangem Twin This action is irreversible. You will not have access to the old wallet. Tap the twin card with number %s and do not remove until the end of the operation + Top up + Topped up Please try again later. If the issue persists, please contact support. Something went wrong! We\'ve encountered an error. Error code: %s. Please contact our support. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt index a0c9c33daa..0cacb2de38 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,6 +33,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.icons.identicon.IdentIcon import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction @@ -70,55 +73,92 @@ fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier @Composable private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { - val rowModifier = modifier - .fillMaxWidth() - .clickable(onClick = state.onClick) - .testTag(TransactionHistoryItemTestTags.ITEM) - - TangemRowContainer( - modifier = rowModifier, - contentPadding = PaddingValues( - horizontal = TangemTheme.dimens2.x4, - vertical = TangemTheme.dimens2.x3, - ), + Column( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = state.onClick) + .testTag(TransactionHistoryItemTestTags.ITEM), ) { - StatusCircle( - iconRes = state.iconRes, - status = state.status, - modifier = Modifier - .layoutId(TangemRowLayoutId.HEAD) - .padding(end = TangemTheme.dimens2.x3) - .size(TangemTheme.dimens2.x10) - .testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix), + TangemRowContainer( + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens2.x4, + vertical = TangemTheme.dimens2.x3, + ), + ) { + StatusCircle( + iconRes = state.iconRes, + status = state.status, + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(TangemTheme.dimens2.x10) + .testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix), + ) + TitleText( + title = state.title, + status = state.status, + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .testTag(TransactionHistoryItemTestTags.TITLE), + ) + SubtitleText( + subtitle = state.subtitle, + status = state.status, + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .padding(top = TangemTheme.dimens2.x0_5), + ) + state.amount?.let { amount -> + AmountText( + amount = amount, + status = state.status, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .layoutId(TangemRowLayoutId.END_TOP) + .testTag(TransactionHistoryItemTestTags.AMOUNT), + ) + } + CurrencyText( + symbol = state.currencySymbol, + modifier = Modifier + .layoutId(TangemRowLayoutId.END_BOTTOM) + .padding(top = TangemTheme.dimens2.x0_5) + .testTag(TransactionHistoryItemTestTags.CURRENCY), + ) + } + state.warning?.let { warning -> + WarningLine( + warning = warning, + modifier = Modifier.padding( + start = TangemTheme.dimens2.x4, + end = TangemTheme.dimens2.x4, + bottom = TangemTheme.dimens2.x3, + ), + ) + } + } +} + +@Composable +private fun WarningLine(warning: TextReference, modifier: Modifier = Modifier) { + val attention = TangemTheme.colors2.text.status.attention + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Icon( + painter = painterResource(R.drawable.ic_alert_triangle_20), + contentDescription = null, + tint = attention, + modifier = Modifier.size(TangemTheme.dimens2.x5), ) - TitleText( - title = state.title, - status = state.status, - modifier = Modifier - .layoutId(TangemRowLayoutId.START_TOP) - .testTag(TransactionHistoryItemTestTags.TITLE), - ) - SubtitleText( - subtitle = state.subtitle, - status = state.status, - modifier = Modifier - .layoutId(TangemRowLayoutId.START_BOTTOM) - .padding(top = TangemTheme.dimens2.x0_5), - ) - AmountText( - amount = state.amount, - status = state.status, - isBalanceHidden = isBalanceHidden, - modifier = Modifier - .layoutId(TangemRowLayoutId.END_TOP) - .testTag(TransactionHistoryItemTestTags.AMOUNT), - ) - CurrencyText( - symbol = state.currencySymbol, - modifier = Modifier - .layoutId(TangemRowLayoutId.END_BOTTOM) - .padding(top = TangemTheme.dimens2.x0_5) - .testTag(TransactionHistoryItemTestTags.CURRENCY), + Text( + text = warning.resolveReference(), + color = attention, + style = TangemTheme.typography2.captionMedium12, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } } @@ -262,6 +302,21 @@ private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Mo modifier = Modifier.fillMaxSize(), ) } + is ContentSubtitle.Asset -> InlineImageSubtitle( + template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.symbol), + color = tertiary, + afterIconColor = if (isFailed) tertiary else primary, + modifier = modifier, + ) { + subtitle.icon?.let { iconState -> + CurrencyIcon( + state = iconState, + shouldDisplayNetwork = false, + withFixedSize = false, + modifier = Modifier.fillMaxSize(), + ) + } + } } } @@ -491,4 +546,73 @@ private fun Preview_TransactionItem_Swap() { } } +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_TransactionItem_Express() { + TangemThemePreviewRedesign { + PreviewColumn( + items = listOf( + TransactionItemUM.Content( + txHash = "exp-swap-u", + amount = "-390.00", + currencySymbol = "USDT", + time = "", + status = Status.Unconfirmed, + direction = Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_exchange_vertical_24, + title = stringReference("Swapping"), + subtitle = ContentSubtitle.Asset( + direction = ContentSubtitle.Direction.TO, + symbol = "POL", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.ic_custom_token_44, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), + timestamp = 0L, + warning = stringReference("KYC verification required by provider"), + ), + TransactionItemUM.Content( + txHash = "exp-onramp-c", + amount = "+0.006339", + currencySymbol = "BTC", + time = "", + status = Status.Confirmed, + direction = Direction.INCOMING, + onClick = {}, + iconRes = R.drawable.ic_tangem_card_24, + title = stringReference("Topped up"), + subtitle = ContentSubtitle.Asset( + direction = ContentSubtitle.Direction.FROM, + symbol = "SEK", + icon = null, + ), + timestamp = 0L, + ), + TransactionItemUM.Content( + txHash = "exp-onramp-f", + amount = "0.006339", + currencySymbol = "BTC", + time = "", + status = Status.Failed, + direction = Direction.INCOMING, + onClick = {}, + iconRes = R.drawable.ic_tangem_card_24, + title = stringReference("Top up failed"), + subtitle = ContentSubtitle.Asset( + direction = ContentSubtitle.Direction.FROM, + symbol = "SEK", + icon = null, + ), + timestamp = 0L, + ), + ), + ) + } +} + // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt index 1c3adfcd0e..7bce8ba673 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt @@ -3,6 +3,7 @@ package com.tangem.core.ui.components.transactions.state import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference @@ -22,12 +23,13 @@ sealed interface TransactionItemUM { /** * Content state. * - * @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded + * @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded. + * `null` hides the numeric value while [currencySymbol] still shows. * @property currencySymbol currency symbol shown alongside [amount], e.g. "BTC", "USDT" */ data class Content( override val txHash: String, - val amount: String, + val amount: String?, val currencySymbol: String, val time: String, val status: Status, @@ -37,6 +39,7 @@ sealed interface TransactionItemUM { val title: TextReference, val subtitle: ContentSubtitle, val timestamp: Long, + val warning: TextReference? = null, ) : TransactionItemUM { @Immutable @@ -95,6 +98,19 @@ sealed interface TransactionItemUM { val deviceIconUM: DeviceIconUM, ) : ContentSubtitle + /** + * Counterparty asset ticker β€” renders as "to/from: ". Used for express rows + * (swap counterparty currency / onramp fiat), e.g. "to: β—Ž POL" or "from: πŸ‡ΈπŸ‡ͺ SEK". + * + * @property icon resolved counterparty currency icon, rendered via `CurrencyIcon`. `null` when no icon + * is available (e.g. onramp fiat carries no `CryptoCurrency`) β€” the ticker then renders without a leading icon. + */ + data class Asset( + val direction: Direction, + val symbol: String, + val icon: CurrencyIconState?, + ) : ContentSubtitle + enum class Direction { TO, FROM } } diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt index 8e30176f4a..d911aa525b 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt @@ -55,13 +55,13 @@ internal class ExpressOnrampConverter : Converter it.activeStatus.isHidden - else -> false - } - }.toPersistentList() + + val expressTxsToDisplay = if (txHistoryFeatureToggles.isNewTxHistoryEnabled) { + persistentListOf() + } else { + expressTxs.filterNot { + when (it) { + is ExpressTransactionStateUM.OnrampUM -> it.activeStatus.isHidden + else -> false + } + }.toPersistentList() + } return state.copy( transactions = expressTxs, transactionsToDisplay = expressTxsToDisplay, 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 new file mode 100644 index 0000000000..46ade74b07 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt @@ -0,0 +1,191 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction as RowDirection +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle.Direction as SubtitleDirection +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.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.features.txhistory.impl.R +import com.tangem.features.txhistory.utils.TxHistoryUiActions +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +/** + * Maps an [ExpressTx] (swap / onramp) row directly to [TransactionItemUM.Content]. + * + * The viewed leg is [CryptoCurrency] ([currency], the token-details currency): outgoing swap shows the pay-in + * (`from`) amount with a minus, incoming swap / onramp shows the received (`to`) amount with a plus. The 26 typed + * 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. + */ +internal class ExpressTxToTransactionItemUMConverter( + private val currency: CryptoCurrency, + private val txHistoryUiActions: TxHistoryUiActions, +) : Converter { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + + override fun convert(value: ExpressTx): TransactionItemUM = when (value) { + is ExpressTx.Swap -> swapContent(value) + is ExpressTx.Onramp -> onrampContent(value) + } + + private fun swapContent(swap: ExpressTx.Swap): TransactionItemUM.Content { + val status = swap.tx.status.toUiStatus() + 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 { + status is Status.Failed -> "" + swap.isOutgoing -> StringsSigns.MINUS + else -> StringsSigns.PLUS + } + return buildContent( + tx = swap, + status = status, + amount = formatAmount(viewedAmount, prefix), + direction = if (swap.isOutgoing) RowDirection.OUTGOING else RowDirection.INCOMING, + iconRes = R.drawable.ic_exchange_vertical_24, + title = swapTitle(status), + subtitle = ContentSubtitle.Asset( + direction = if (swap.isOutgoing) SubtitleDirection.TO else SubtitleDirection.FROM, + symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId, + icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert), + ), + // TODO: replace null to warning logic. + warning = null, + ) + } + + private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content { + val status = onramp.tx.status.toUiStatus() + val prefix = when { + status is Status.Failed -> "" + status is Status.Confirmed -> StringsSigns.PLUS + else -> StringsSigns.TILDE_SIGN + } + return buildContent( + tx = onramp, + status = status, + amount = formatAmount(onramp.tx.toAsset.amount, prefix), + direction = RowDirection.INCOMING, + iconRes = R.drawable.ic_tangem_card_24, + title = onrampTitle(status), + subtitle = ContentSubtitle.Asset( + direction = SubtitleDirection.FROM, + symbol = onramp.tx.fromFiat.currencySymbol, + // TODO: fiat carries no OnrampCurrency, so no icon yet β€” render with a fiat country flag once available. + icon = null, + ), + // TODO: replace null to warning logic. + warning = null, + ) + } + + @Suppress("LongParameterList") + private fun buildContent( + tx: ExpressTx, + status: Status, + amount: String?, + direction: RowDirection, + iconRes: Int, + title: TextReference, + subtitle: ContentSubtitle, + warning: TextReference?, + ): TransactionItemUM.Content { + val explorerHash = tx.matchHash ?: tx.txId + return TransactionItemUM.Content( + txHash = explorerHash, + amount = amount, + currencySymbol = currency.symbol, + time = tx.timestampMillis.toTimeFormat(), + status = status, + direction = direction, + onClick = { txHistoryUiActions.openTxInExplorer(explorerHash) }, + iconRes = iconRes, + title = title, + subtitle = subtitle, + timestamp = tx.timestampMillis, + warning = warning, + ) + } + + private fun formatAmount(amount: BigDecimal?, prefix: String): String? = + amount?.let { prefix + it.format { crypto(symbol = "", decimals = currency.decimals) }.trim() } + + private fun swapTitle(status: Status): TextReference = when (status) { + is Status.Confirmed -> resourceReference(R.string.common_swapped) + is Status.Unconfirmed -> resourceReference(R.string.common_swapping) + is Status.Failed -> + resourceReference(R.string.common_action_failed, wrappedList(resourceReference(R.string.common_swapping))) + } + + private fun onrampTitle(status: Status): TextReference = when (status) { + is Status.Confirmed -> resourceReference(R.string.tx_history_onramp_topped_up) + is Status.Unconfirmed -> resourceReference(R.string.tx_history_onramp_top_up) + is Status.Failed -> resourceReference( + R.string.common_action_failed, + 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 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 574f6658c0..a52b20b829 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,21 +4,20 @@ 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.toSyntheticTxInfo import com.tangem.utils.converter.Converter /** - * Converts a merged [TxHistoryInfo] row to [TransactionItemUM], delegating to the on-chain - * [TxHistoryItemToTransactionItemUMConverter]: on-chain rows convert their `TxInfo` directly, express - * rows convert a synthesized `TxInfo` view (see [toSyntheticTxInfo]). + * Converts a merged [TxHistoryInfo] row to [TransactionItemUM]: on-chain rows convert their `TxInfo` via + * [TxHistoryItemToTransactionItemUMConverter]; express rows map directly via [ExpressTxToTransactionItemUMConverter]. */ internal class TxHistoryInfoToTransactionItemUMConverter( private val txInfoConverter: TxHistoryItemToTransactionItemUMConverter, + private val expressConverter: ExpressTxToTransactionItemUMConverter, ) : Converter { override fun convert(value: TxHistoryInfo): TransactionItemUM = when (value) { is OnChainTx -> convertOnChain(value) - is ExpressTx -> txInfoConverter.convert(value.toSyntheticTxInfo()) + is ExpressTx -> expressConverter.convert(value) } private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) { 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 b64a5d10d9..9826f149c0 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 @@ -28,6 +28,7 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.wallets.usecase.GetWalletIconUseCase import com.tangem.domain.txhistory.TxHistoryFeatureToggles import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.converter.ExpressTxToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter @@ -39,6 +40,7 @@ import com.tangem.features.txhistory.utils.HistoryTxListManager import com.tangem.features.txhistory.utils.TxHistoryListManager import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.pagination.PaginationStatus +import com.tangem.utils.annotations.RemoveWithToggle import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.ImmutableList @@ -100,9 +102,11 @@ internal class TxHistoryModel @Inject constructor( emptyFlow() } + @RemoveWithToggle("APP_REDESIGN_ENABLED") private val legacyTxHistoryItemConverter = TxHistoryItemToTransactionStateConverter(currency = params.currency, txHistoryUiActions = this) + @RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED") private val txHistoryListManager: TxHistoryListManager? = if (!txHistoryFeatureToggle.isNewTxHistoryEnabled) { TxHistoryListManager( repository = repository, @@ -193,7 +197,6 @@ internal class TxHistoryModel @Inject constructor( } } - // Temporary: express rows are mapped to UI via a synthesized TxInfo (see ExpressTx.toSyntheticTxInfo). private fun buildUiItems( merged: List, lookup: TxHistoryLookupContext, @@ -204,6 +207,10 @@ internal class TxHistoryModel @Inject constructor( txHistoryUiActions = this, lookupContext = lookup, ), + expressConverter = ExpressTxToTransactionItemUMConverter( + currency = params.currency, + txHistoryUiActions = this, + ), ) val items = mutableListOf() diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt index e310aeca94..1830b1be74 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt @@ -1,7 +1,5 @@ package com.tangem.features.txhistory.utils -import com.tangem.domain.express.models.ExpressExchangeStatus -import com.tangem.domain.express.models.ExpressOnrampStatus import com.tangem.domain.models.network.TxInfo import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.OnChainTx @@ -50,48 +48,4 @@ private fun ExpressTx.withMatchedTxInfo(txInfo: TxInfo): ExpressTx { is ExpressTx.Swap -> copy(txInfo = matched) is ExpressTx.Onramp -> copy(txInfo = matched) } -} - -/** - * Synthesizes a [TxInfo] view of an express op so it can be rendered by the existing - * [com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter]. Rendered as a - * [TxInfo.TransactionType.Swap] for now (onramp included). The amount is the viewed-currency leg. - */ -internal fun ExpressTx.toSyntheticTxInfo(): TxInfo { - val viewedAmount = when (this) { - is ExpressTx.Swap -> if (isOutgoing) tx.fromAsset.amount else tx.toAsset.amount - is ExpressTx.Onramp -> tx.toAsset.amount - } - val isOutgoing = when (this) { - is ExpressTx.Swap -> this.isOutgoing - is ExpressTx.Onramp -> false - } - return TxInfo( - // matchHash is the on-chain hash (== the matched leg's hash, enables the explorer link); else txId. - txHash = matchHash ?: txId, - timestampInMillis = timestampMillis, - isOutgoing = isOutgoing, - destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(address = "")), - sourceType = TxInfo.SourceType.Single(address = ""), - interactionAddressType = null, - status = toTransactionStatus(), - type = TxInfo.TransactionType.Swap, - amount = viewedAmount, - ) -} - -/** - * Maps the typed express status to the on-chain-shaped [TxInfo.TransactionStatus] used by the UI: - * the single success state (`Finished`) β†’ Confirmed, any other terminal state β†’ Failed, in-progress β†’ Unconfirmed. - */ -private fun ExpressTx.toTransactionStatus(): TxInfo.TransactionStatus { - val isFinished = when (this) { - is ExpressTx.Swap -> tx.status == ExpressExchangeStatus.Finished - is ExpressTx.Onramp -> tx.status == ExpressOnrampStatus.Finished - } - return when { - isFinished -> TxInfo.TransactionStatus.Confirmed - isTerminal -> TxInfo.TransactionStatus.Failed - else -> TxInfo.TransactionStatus.Unconfirmed - } } \ 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 new file mode 100644 index 0000000000..ee4a06fb0f --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt @@ -0,0 +1,289 @@ +package com.tangem.features.txhistory.converter + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle +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.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.tokens.model.Amount +import com.tangem.domain.tokens.model.AmountType +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.features.txhistory.impl.R +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 ExpressTxToTransactionItemUMConverterTest { + + private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true) + private val coin: CryptoCurrency.Coin = createCoin(symbol = "ETH", decimals = 18) + + private val converter = ExpressTxToTransactionItemUMConverter( + currency = coin, + txHistoryUiActions = txHistoryUiActions, + ) + + // region Status β†’ bucket + + @Test + fun `GIVEN every swap status WHEN convert THEN mapped to expected status bucket`() { + val cases = mapOf( + ExpressExchangeStatus.Finished to Status.Confirmed, + ExpressExchangeStatus.Failed to Status.Failed, + ExpressExchangeStatus.TxFailed to Status.Failed, + ExpressExchangeStatus.Refunded to Status.Failed, + ExpressExchangeStatus.Expired to Status.Failed, + ExpressExchangeStatus.Unknown to Status.Failed, + ExpressExchangeStatus.Preview to Status.Unconfirmed, + ExpressExchangeStatus.Created to Status.Unconfirmed, + ExpressExchangeStatus.ExchangeTxSent to Status.Unconfirmed, + ExpressExchangeStatus.Waiting to Status.Unconfirmed, + ExpressExchangeStatus.WaitingTxHash to Status.Unconfirmed, + ExpressExchangeStatus.Confirming to Status.Unconfirmed, + ExpressExchangeStatus.Exchanging to Status.Unconfirmed, + ExpressExchangeStatus.Sending to Status.Unconfirmed, + ExpressExchangeStatus.Verifying to Status.Unconfirmed, + ExpressExchangeStatus.Paused to Status.Unconfirmed, + ) + // every enum entry is covered (guards against new statuses silently falling through) + assertThat(cases.keys).containsExactlyElementsIn(ExpressExchangeStatus.entries) + + cases.forEach { (status, expected) -> + val result = converter.convert(createSwap(status = status)) as TransactionItemUM.Content + assertWithMessage(status.name).that(result.status).isEqualTo(expected) + } + } + + @Test + fun `GIVEN every onramp status WHEN convert THEN mapped to expected status bucket`() { + val cases = mapOf( + ExpressOnrampStatus.Finished to Status.Confirmed, + ExpressOnrampStatus.Failed to Status.Failed, + ExpressOnrampStatus.Expired to Status.Failed, + ExpressOnrampStatus.Unknown to Status.Failed, + ExpressOnrampStatus.Created to Status.Unconfirmed, + ExpressOnrampStatus.WaitingForPayment to Status.Unconfirmed, + ExpressOnrampStatus.PaymentProcessing to Status.Unconfirmed, + ExpressOnrampStatus.Verifying to Status.Unconfirmed, + ExpressOnrampStatus.Paid to Status.Unconfirmed, + ExpressOnrampStatus.Sending to Status.Unconfirmed, + ExpressOnrampStatus.Paused to Status.Unconfirmed, + ) + assertThat(cases.keys).containsExactlyElementsIn(ExpressOnrampStatus.entries) + + cases.forEach { (status, expected) -> + val result = converter.convert(createOnramp(status = status)) as TransactionItemUM.Content + assertWithMessage(status.name).that(result.status).isEqualTo(expected) + } + } + + // endregion + + // region Amount sign / prefix + + @Test + fun `GIVEN outgoing swap WHEN convert THEN amount is negative from-leg`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true), + ) as TransactionItemUM.Content + + assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.OUTGOING) + assertThat(result.amount).startsWith("-") + assertThat(result.amount).contains("1.5") + } + + @Test + fun `GIVEN incoming swap WHEN convert THEN amount is positive to-leg`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = false), + ) as TransactionItemUM.Content + + assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.INCOMING) + assertThat(result.amount).startsWith("+") + assertThat(result.amount).contains("0.001") + } + + @Test + fun `GIVEN finished onramp WHEN convert THEN amount prefixed with plus`() { + val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content + assertThat(result.amount).startsWith("+") + } + + @Test + fun `GIVEN in-progress onramp WHEN convert THEN amount prefixed with tilde`() { + val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content + assertThat(result.amount).startsWith("~") + } + + @Test + fun `GIVEN failed onramp WHEN convert THEN amount has no sign prefix`() { + val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Failed)) as TransactionItemUM.Content + assertThat(requireNotNull(result.amount).first()) + .isIn(listOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9')) + } + + @Test + fun `GIVEN swap with null viewed amount WHEN convert THEN amount is null`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true, fromAmount = null), + ) as TransactionItemUM.Content + + assertThat(result.amount).isNull() + } + + @Test + fun `GIVEN onramp with null amount WHEN convert THEN amount is null`() { + val result = converter.convert( + createOnramp(status = ExpressOnrampStatus.Sending, toAmount = null), + ) as TransactionItemUM.Content + + assertThat(result.amount).isNull() + } + + // endregion + + // region Title / subtitle / warning / click + + @Test + fun `GIVEN swap statuses WHEN convert THEN status-aware title`() { + val swapping = converter.convert(createSwap(status = ExpressExchangeStatus.Waiting)) as TransactionItemUM.Content + val swapped = converter.convert(createSwap(status = ExpressExchangeStatus.Finished)) as TransactionItemUM.Content + + assertThat(swapping.title).isEqualTo(resourceReference(R.string.common_swapping)) + assertThat(swapped.title).isEqualTo(resourceReference(R.string.common_swapped)) + } + + @Test + fun `GIVEN onramp statuses WHEN convert THEN status-aware title`() { + val topUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content + val toppedUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content + + assertThat(topUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_top_up)) + assertThat(toppedUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_topped_up)) + } + + @Test + fun `GIVEN outgoing swap WHEN convert THEN subtitle shows TO counterparty ticker`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true), + ) as TransactionItemUM.Content + + val subtitle = result.subtitle as ContentSubtitle.Asset + assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO) + assertThat(subtitle.symbol).isEqualTo("btc") // mock: counterparty (to-leg) networkId + } + + @Test + fun `GIVEN onramp WHEN convert THEN subtitle shows FROM fiat code`() { + val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content + + val subtitle = result.subtitle as ContentSubtitle.Asset + assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM) + assertThat(subtitle.symbol).isEqualTo("SEK") + } + + @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 + + result.onClick() + + verify { txHistoryUiActions.openTxInExplorer("0xhash") } + } + + // endregion + + private fun createSwap( + status: ExpressExchangeStatus, + matchHash: String? = null, + isOutgoing: Boolean = true, + fromAmount: BigDecimal? = BigDecimal("1.5"), + toAmount: BigDecimal? = BigDecimal("0.001"), + ) = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "tx-1", + status = status, + createdAtMillis = 100, + provider = null, + payinHash = matchHash.takeIf { isOutgoing }, + payoutHash = matchHash.takeUnless { isOutgoing }, + fromAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "eth", contractAddress = "0"), + amount = fromAmount, + decimals = 18, + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"), + amount = toAmount, + decimals = 8, + ), + ), + isOutgoing = isOutgoing, + txInfo = null, + ) + + private fun createOnramp( + status: ExpressOnrampStatus, + toAmount: BigDecimal? = BigDecimal("0.006339"), + ) = ExpressTx.Onramp( + tx = OnrampTransaction( + txId = "tx-2", + status = status, + createdAtMillis = 100, + provider = null, + payoutHash = null, + fromFiat = Amount( + currencySymbol = "SEK", + value = BigDecimal("100"), + decimals = 2, + type = AmountType.FiatType(code = "SEK"), + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "btc", contractAddress = "0"), + amount = toAmount, + decimals = 8, + ), + ), + txInfo = null, + ) + + private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID(rawId = "ethereum"), + ), + network = Network( + id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None), + name = "Ethereum", + currencySymbol = symbol, + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Ethereum", + symbol = symbol, + decimals = decimals, + iconUrl = null, + isCustom = false, + ) +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt index ae76d9a485..ed577e236b 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt @@ -161,8 +161,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { assertThat(result.subtitle).isEqualTo( ContentSubtitle.Plain(resRef(R.string.transaction_history_earned_from_stake)), ) - assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse() - assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse() } @Test @@ -514,7 +514,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { val result = coinConverter.convert(tx) as TransactionItemUM.Content - assertThat(result.amount.startsWith(StringsSigns.MINUS)).isTrue() + assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isTrue() } @Test @@ -528,7 +528,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { val result = coinConverter.convert(tx) as TransactionItemUM.Content - assertThat(result.amount.startsWith(StringsSigns.PLUS)).isTrue() + assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isTrue() } @Test @@ -543,8 +543,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { val result = coinConverter.convert(tx) as TransactionItemUM.Content - assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse() - assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse() } @Test @@ -558,8 +558,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { val result = coinConverter.convert(tx) as TransactionItemUM.Content - assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse() - assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse() } // endregion diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt index 7413a5ae9f..f4a022d2c1 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt @@ -85,21 +85,6 @@ internal class TxHistoryInfoMergerTest { assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder() } - @Test - fun `GIVEN outgoing swap WHEN toSyntheticTxInfo THEN viewed from-leg amount and swap type`() { - // Arrange - val swap = createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting, isOutgoing = true) - - // Act - val txInfo = swap.toSyntheticTxInfo() - - // Assert - assertThat(txInfo.isOutgoing).isTrue() - assertThat(txInfo.amount).isEqualTo(BigDecimal("1.5")) - assertThat(txInfo.type).isEqualTo(TxInfo.TransactionType.Swap) - assertThat(txInfo.status).isEqualTo(TxInfo.TransactionStatus.Unconfirmed) - } - private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo( txHash = txHash, timestampInMillis = timestamp,