diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt index 89aaa014aa..5b8e158f7c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt @@ -52,11 +52,13 @@ open class BigDecimalCryptoFormatStyled( fun BigDecimalFormatScope.crypto( symbol: String, decimals: Int, + ignoreSymbolPosition: Boolean = false, locale: Locale = Locale.getDefault(), ): BigDecimalCryptoFormat { return BigDecimalCryptoFormat( symbol = symbol, decimals = decimals, + shouldIgnoreSymbolPosition = ignoreSymbolPosition, locale = locale, ) } diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 245b8d24c9..8bd1f5e086 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.account.status) + implementation(projects.domain.onramp.models) /* AndroidX */ implementation(deps.androidx.activity.compose) 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 6cec8475d1..628d829e53 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 @@ -4,6 +4,7 @@ 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.currency.icon.CurrencyIconState 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 @@ -12,6 +13,8 @@ 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 @@ -29,7 +32,7 @@ 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 routes through + * onramp shows the fiat code with the onramp country flag as the icon (fiat carries no `CryptoCurrency`). The row click routes through * [TxHistoryUiActions.onTransactionClick] (express rows open the in-app details sheet). */ internal class ExpressTxToTransactionItemUMConverter( @@ -67,16 +70,15 @@ internal class ExpressTxToTransactionItemUMConverter( symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId, icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert), ), - // TODO: replace null to warning logic. - warning = null, + warning = swapWarning(swap), ) } private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content { val status = onrampStatusConverter.convert(onramp.tx.status) - val prefix = when { - status is Status.Failed -> "" - status is Status.Confirmed -> StringsSigns.PLUS + val prefix = when (status) { + is Status.Failed -> "" + is Status.Confirmed -> StringsSigns.PLUS else -> StringsSigns.TILDE_SIGN } return buildContent( @@ -89,11 +91,12 @@ internal class ExpressTxToTransactionItemUMConverter( 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, + icon = CurrencyIconState.FiatIcon( + url = onramp.tx.country?.image, + fallbackResId = R.drawable.ic_currency_24, + ), ), - // TODO: replace null to warning logic. - warning = null, + warning = onrampWarning(onramp), ) } @@ -143,4 +146,24 @@ internal class ExpressTxToTransactionItemUMConverter( wrappedList(resourceReference(R.string.tx_history_onramp_top_up)), ) } + + /** + * KYC-verification warning. Other "problem" statuses (failed / refunded / expired) already surface as the red + * [Status.Failed] row title, so they need no extra warning line; only [ExpressExchangeStatus.Verifying] — + * which buckets into the in-progress [Status.Unconfirmed] — requires it to signal the pending user action. + */ + private fun swapWarning(swap: ExpressTx.Swap): TextReference? = + if (swap.tx.status == ExpressExchangeStatus.Verifying) { + resourceReference(R.string.express_exchange_notification_verification_title) + } else { + null + } + + /** KYC-verification warning; see [swapWarning] for why failed statuses are intentionally excluded. */ + private fun onrampWarning(onramp: ExpressTx.Onramp): TextReference? = + if (onramp.tx.status == ExpressOnrampStatus.Verifying) { + resourceReference(R.string.express_exchange_notification_verification_title) + } else { + null + } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt index 71b734ba25..e0b98c3582 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt @@ -8,11 +8,18 @@ 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.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.domain.express.models.ExpressProvider +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.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.domain.txhistory.model.TxHistoryInfo @@ -23,20 +30,25 @@ 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.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import org.joda.time.DateTime +import java.math.BigDecimal /** * Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card. * * The dispatch mirrors the row converters: an [OnChainTx.BSDK] always renders as [TxHistoryDetailsUM.SingleAsset] - * (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) currently - * produces a header-only [TxHistoryDetailsUM.TwoAssets] with the express status banner. The express legs (`from`/`to` - * amounts, currencies, fiat) are populated in a follow-up ([REDACTED_TASK_KEY]). + * (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) renders as + * [TxHistoryDetailsUM.TwoAssets] — the `from`/`to` legs come from the express deal ([ExchangeTransaction] asset pair / + * [OnrampTransaction] fiat→asset), and the network-fee row comes from the matched on-chain leg ([ExpressTx.txInfo]). */ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( private val currency: CryptoCurrency, private val onCopyAddress: (String) -> Unit, + /** Own deposit addresses for this currency's network — used to label own-transfers as "Transfer". */ + private val ownAddresses: Set = emptySet(), ) : Converter { private val iconStateConverter = CryptoCurrencyToIconStateConverter() @@ -60,8 +72,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( 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(), + // Network fee from the tx itself; rate is not surfaced (no data). + rows = value.toInfoRows(), ) private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( @@ -74,9 +86,6 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( 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, ) @@ -102,10 +111,34 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( private fun TxInfo.counterpartyLabel(): TextReference = if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from) + private fun TxInfo.headerTitle(): TextReference = when (type) { + is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped) + is TransactionType.Transfer -> transferTitle() + else -> stringReference(type.toString()) + } + + /** + * Transfer header label, mirroring the history row: a transfer between the user's own accounts/wallets reads + * "Transfer", an outgoing transfer to an external address "Send", an incoming one "Receive" (status-aware). + */ + private fun TxInfo.transferTitle(): TextReference { + val counterpartyAddress = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address + val isOwnTransfer = counterpartyAddress != null && counterpartyAddress in ownAddresses + return when { + isOwnTransfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred) + isOutgoing -> statusAwareTitle(R.string.common_sending, R.string.common_sent) + else -> statusAwareTitle(R.string.common_receiving, R.string.common_received) + } + } + // endregion // region Express (swap / onramp) + /** + * The two-asset block always renders the deal's `fromAsset`→`toAsset` regardless of [ExpressTx.Swap.isOutgoing] — + * `isOutgoing` only selects which leg is the *viewed* one in the history row, it does not reorder the detail legs. + */ private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets { val status = exchangeStatusConverter.convert(swap.tx.status) return TxHistoryDetailsUM.TwoAssets( @@ -115,7 +148,18 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped), subtitle = headerSubtitle(swap.timestampMillis), ), - statusBanner = status.toStatusBannerUM(), + from = swap.tx.fromAsset.toAssetUM( + label = resourceReference(R.string.swapping_from_title_v2), + sign = status.outgoingSign(), + isFaded = status is Status.Failed, + ), + to = swap.tx.toAsset.toAssetUM( + label = resourceReference(R.string.swapping_to_title), + sign = status.incomingSign(), + isFaded = status is Status.Failed, + ), + statusBanner = swap.tx.status.toStatusBannerUM(), + rows = swap.toInfoRows(), ) } @@ -131,7 +175,59 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( ), subtitle = headerSubtitle(onramp.timestampMillis), ), - statusBanner = status.toStatusBannerUM(), + from = onramp.tx.fromFiat.toFiatAssetUM( + label = resourceReference(R.string.swapping_from_title_v2), + isFaded = status is Status.Failed, + ), + to = onramp.tx.toAsset.toAssetUM( + label = resourceReference(R.string.swapping_to_title), + sign = status.incomingSign(), + isFaded = status is Status.Failed, + ), + statusBanner = onramp.tx.status.toStatusBannerUM(), + rows = onramp.toInfoRows(), + ) + } + + /** + * Builds one crypto leg of the two-asset block. The ticker symbol and icon come from the resolved + * [ExpressTransactionAsset.cryptoCurrency]; when it is unresolved the symbol falls back to the network id and the + * icon slot is left empty ([currencyIcon] = `null`). + */ + private fun ExpressTransactionAsset.toAssetUM( + label: TextReference, + sign: String, + isFaded: Boolean, + ): TxHistoryDetailsUM.AssetUM { + val symbol = cryptoCurrency?.symbol ?: id.networkId + val formatted = amount.format { crypto( + symbol = symbol, + decimals = decimals, + ignoreSymbolPosition = true, + ) }.trim() + return TxHistoryDetailsUM.AssetUM( + label = label, + owner = null, + amount = stringReference((sign + formatted).trim()), + currencyIcon = cryptoCurrency?.let(iconStateConverter::convert), + isFaded = isFaded, + ) + } + + /** + * Builds the fiat ("You paid") leg of an onramp. The paid fiat amount is exact and carries no sign — neither `+`/`−` + * nor the `~` estimate — so only the value is shown. Fiat has no `CryptoCurrency`, so it also has no icon. + */ + private fun Amount.toFiatAssetUM(label: TextReference, isFaded: Boolean): TxHistoryDetailsUM.AssetUM { + val code = (type as? AmountType.FiatType)?.code ?: currencySymbol + val formatted = (value ?: BigDecimal.ZERO) + .format { fiat(fiatCurrencyCode = code, fiatCurrencySymbol = currencySymbol) } + return TxHistoryDetailsUM.AssetUM( + label = label, + owner = null, + amount = stringReference(formatted.trim()), + currencyIcon = null, + isFaded = isFaded, ) } @@ -141,30 +237,94 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( // region Status helpers /** - * Express status plaque under the two-asset block, keyed on the collapsed UI [Status] bucket. + * Express swap status → the status plaque under the two-asset block. * - * A stopgap shared by on-chain swaps and express ops — [Severity.Warning] (verification) is not reachable here yet. - * [REDACTED_TODO_COMMENT] + * In-flight stages render as [Severity.Info] with the rotating loader; [Verifying][ExpressExchangeStatus.Verifying] + * (KYC) and the paused / refunded terminals as [Severity.Warning]; the failure terminals as [Severity.Error]; the + * [Finished][ExpressExchangeStatus.Finished] success as [Severity.Success] (the plaque then auto-collapses — see + * `TxHistoryDetailsStatusBanner`). [Unknown][ExpressExchangeStatus.Unknown] carries nothing to show, so it hides the + * plaque (`null`). */ -private fun Status.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (this) { - is Status.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Info, - title = resourceReference(R.string.express_exchange_status_receiving_active), - isLoading = true, - ) - is Status.Confirmed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Success, - title = resourceReference(R.string.express_exchange_status_exchanged), - isLoading = false, - ) - is Status.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 ExpressExchangeStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) { + ExpressExchangeStatus.Preview, + ExpressExchangeStatus.Created, + ExpressExchangeStatus.ExchangeTxSent, + ExpressExchangeStatus.Waiting, + -> loadingBanner(R.string.express_exchange_status_receiving_active) + ExpressExchangeStatus.WaitingTxHash -> loadingBanner(R.string.express_exchange_status_waiting_tx_hash) + ExpressExchangeStatus.Confirming -> loadingBanner(R.string.express_exchange_status_confirming_active) + ExpressExchangeStatus.Exchanging -> loadingBanner(R.string.express_exchange_status_exchanging_active) + ExpressExchangeStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active) + ExpressExchangeStatus.Verifying -> verificationBanner() + ExpressExchangeStatus.Refunded -> warningBanner(R.string.express_exchange_status_refunded) + ExpressExchangeStatus.Paused -> warningBanner(R.string.express_exchange_status_paused) + ExpressExchangeStatus.Failed, + ExpressExchangeStatus.TxFailed, + -> failedBanner() + ExpressExchangeStatus.Expired -> errorBanner(R.string.express_exchange_status_failed) + ExpressExchangeStatus.Finished -> successBanner(R.string.express_exchange_status_exchanged) + ExpressExchangeStatus.Unknown -> null } +/** + * Express onramp status → the status plaque under the two-asset block. Same severity mapping as the swap variant; the + * [Finished][ExpressOnrampStatus.Finished] success ("Purchase completed") is the only [Severity.Success] (auto-collapsed). + */ +private fun ExpressOnrampStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) { + ExpressOnrampStatus.Created, + ExpressOnrampStatus.WaitingForPayment, + -> loadingBanner(R.string.express_exchange_status_receiving_active) + ExpressOnrampStatus.PaymentProcessing -> loadingBanner(R.string.express_exchange_status_confirming_active) + ExpressOnrampStatus.Verifying -> verificationBanner() + ExpressOnrampStatus.Paid -> loadingBanner(R.string.express_exchange_status_buying_active) + ExpressOnrampStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active) + ExpressOnrampStatus.Paused -> warningBanner(R.string.express_exchange_status_paused) + ExpressOnrampStatus.Failed -> failedBanner() + ExpressOnrampStatus.Expired -> errorBanner(R.string.express_exchange_status_failed) + ExpressOnrampStatus.Finished -> successBanner(R.string.express_exchange_status_bought) + ExpressOnrampStatus.Unknown -> null +} + +private fun loadingBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Info, + title = resourceReference(title), + isLoading = true, +) + +private fun successBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Success, + title = resourceReference(title), + isLoading = false, +) + +private fun warningBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Warning, + title = resourceReference(title), + isLoading = false, +) + +private fun errorBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Error, + title = resourceReference(title), + isLoading = false, +) + +/** Failure terminal: red plaque with the shared "visit provider to refund" hint. */ +private fun failedBanner() = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Error, + title = resourceReference(R.string.express_exchange_status_failed), + subtitle = resourceReference(R.string.express_exchange_notification_failed_text), + isLoading = false, +) + +/** KYC verification: amber plaque with the "visit provider for verification" hint. */ +private fun verificationBanner() = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Warning, + title = resourceReference(R.string.express_exchange_status_verifying), + subtitle = resourceReference(R.string.express_exchange_notification_verification_text), + isLoading = false, +) + private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (this) { is Status.Failed -> resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) is Status.Unconfirmed -> resourceReference(pending) @@ -173,8 +333,59 @@ private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirme // endregion +// region Info rows (network fee) + +/** Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). */ +private fun TxInfo.toInfoRows(): ImmutableList = listOfNotNull(feeRow()).toImmutableList() + +/** + * Detail rows of an express op: the [provider] row (its name) followed by the network-fee row from the matched on-chain + * leg. The provider row is dropped while the provider is unresolved; the fee row while no on-chain leg / fee is present. + * (Rate is not surfaced yet — no data.) + */ +private fun ExpressTx.toInfoRows(): ImmutableList = buildList { + provider?.let { add(it.providerRow()) } + addAll(txInfo.toInfoRows()) +}.toImmutableList() + +private fun ExpressProvider.providerRow(): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.express_provider), + value = stringReference(name), + trailingIconRes = R.drawable.ic_arrow_top_right_24, +) + +/** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */ +private fun OnChainTx?.toInfoRows(): ImmutableList = + (this as? OnChainTx.BSDK)?.txInfo?.toInfoRows() ?: persistentListOf() + +private fun TxInfo.feeRow(): TxHistoryDetailsUM.InfoRowUM? { + val fee = fee ?: return null + val value = fee.value ?: return null + return TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.common_network_fee_title), + value = stringReference( + value.format { crypto(symbol = fee.currencySymbol, decimals = fee.decimals, ignoreSymbolPosition = true) }, + ), + ) +} + +// endregion + // region Amount building helpers +/** Leading sign of the pay-in / "You send" leg: `−` while in flight or settled, dropped on a failed deal. */ +private fun Status.outgoingSign(): String = if (this is Status.Failed) "" else "${StringsSigns.MINUS} " + +/** + * Leading sign of the payout / "You receive" leg: `~` while in flight (the final received amount is still an estimate), + * `+` once the funds have settled, and dropped on a failed deal (the amount is then only struck through). + */ +private fun Status.incomingSign(): String = when (this) { + is Status.Unconfirmed -> "${StringsSigns.TILDE_SIGN} " + is Status.Confirmed -> "${StringsSigns.PLUS} " + is Status.Failed -> "" +} + /** * 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 @@ -201,12 +412,6 @@ private fun TxInfo.headerIcon(): Int = when (type) { 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 headerSubtitle(timestampMillis: Long): TextReference { val dateTime = DateTime(timestampMillis) val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index de525f1387..1a6e454f79 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** * UI model for the in-app transaction details ("Operation") card. @@ -35,15 +36,18 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { /** * Two-asset layout: Swap / Onramp. * - * [from] ("You sent") → [to] ("You receive") exchange block. Both are nullable: the converter can't populate the - * legs yet (`TxInfo` exposes no swap amounts/currencies/fiat), so the card falls back to a header-only placeholder - * until that data lands. [statusBanner] is the express status plaque under the block, `null` until status is known. + * [from] ("You send") → [to] ("You receive") exchange block. Both are nullable: when a leg cannot be built (e.g. a + * future express variant with no asset data) the card falls back to a header-only placeholder. [statusBanner] is + * the express status plaque under the block, `null` until status is known. [rows] carries the provider row (its + * name) followed by the network-fee row pulled from the matched on-chain leg (`ExpressTx.txInfo`); each is dropped + * when its data is unavailable (rate is not surfaced yet — no data). */ data class TwoAssets( override val header: HeaderUM, val from: AssetUM? = null, val to: AssetUM? = null, val statusBanner: StatusBannerUM? = null, + val rows: ImmutableList = persistentListOf(), ) : TxHistoryDetailsUM /** @@ -67,14 +71,18 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { /** * One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing - * side. [owner] `null` → plain label ("You sent"); non-null → "From"/"To" prefix plus the resolved own account / - * wallet decoration. [isFaded] renders the unsettled/failed amount (struck through, recolored to tertiary). + * side. [owner] `null` → plain label ("You send"); non-null → "From"/"To" prefix plus the resolved own account / + * wallet decoration. [isFaded] renders the failed amount (struck through, recolored to tertiary); an in-flight leg is + * not faded — it carries a `~` estimate sign instead. + * + * [currencyIcon] is `null` when the leg has no icon to show — the onramp fiat side carries no `CryptoCurrency` and + * no country flag is rendered (no data); the trailing icon slot is then left empty. */ data class AssetUM( val label: TextReference, val owner: AssetOwnerUM?, val amount: TextReference, - val currencyIcon: CurrencyIconState, + val currencyIcon: CurrencyIconState?, val isFaded: Boolean, ) @@ -106,23 +114,30 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto * [amount] and the secondary [fiatAmount]. * + * [fiatAmount] is `null` while no fiat value is available (`TxInfo` has no fiat field yet) — the fiat line is then + * omitted entirely rather than shown as a placeholder. + * * [isFailed] drives the failed visual state — the amount is struck through, recolored to tertiary and carries no * `+`/`−` sign (mirrors the status-driven recolor in the shared header). */ data class AmountBlockUM( val currencyIcon: CurrencyIconState, val amount: TextReference, - val fiatAmount: TextReference, + val fiatAmount: TextReference? = null, val isFailed: Boolean, ) /** * A single info row of the details card: a [label] on the leading side and its [value] on the trailing side * (e.g. `Network fee` → `0.00056 ETH`, `Rate` → `1 POL ≈ 0.36 USDT`). Rendered by [TxHistoryDetailsInfoRows]. + * + * [trailingIconRes] is an optional glyph drawn after the [value] (e.g. the arrow-up-right link affordance on the + * provider row); `null` leaves the trailing slot text-only. */ data class InfoRowUM( val label: TextReference, val value: TextReference, + @DrawableRes val trailingIconRes: Int? = null, ) /** 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 59c3845cb1..52c3732eab 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 @@ -5,12 +5,16 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.features.txhistory.component.TxHistoryDetailsComponent import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -21,18 +25,27 @@ import javax.inject.Inject internal class TxHistoryDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val clipboardManager: ClipboardManager, + multiAccountStatusListSupplier: MultiAccountStatusListSupplier, paramsContainer: ParamsContainer, ) : Model() { private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() - private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( - currency = params.currency, - onCopyAddress = ::onCopyAddress, - ) + /** Own deposit addresses for the viewed currency's network — drives the own-vs-external transfer title. */ + private val ownAddressesFlow: Flow> = multiAccountStatusListSupplier() + .map { lists -> buildOwnAccountAddressMap(lists, params.currency.network.id.rawId).keys } + .distinctUntilChanged() - val uiState: StateFlow = params.txHistoryInfo - .map(converter::convert) + val uiState: StateFlow = combine( + params.txHistoryInfo, + ownAddressesFlow, + ) { txInfo, ownAddresses -> + TxHistoryInfoToTxHistoryDetailsUMConverter( + currency = params.currency, + onCopyAddress = ::onCopyAddress, + ownAddresses = ownAddresses, + ).convert(txInfo) + } .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt index 32f290cf21..f8e90d6a5b 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt @@ -1,7 +1,10 @@ package com.tangem.features.txhistory.model import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId /** @@ -17,4 +20,28 @@ internal data class TxHistoryLookupContext( val walletInfoById: Map, ) -internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM) \ No newline at end of file +internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM) + +/** + * Flattens every crypto-portfolio account of every wallet into an `address -> account` map for the network identified + * by [networkRawId]. Shared by the history list and the details screen to decide whether a transfer counterparty is one + * of the user's own accounts/wallets. + */ +internal fun buildOwnAccountAddressMap( + lists: List, + networkRawId: Network.RawID, +): Map { + val map = mutableMapOf() + lists.forEach { accountList -> + accountList.accountStatuses + .filterCryptoPortfolio() + .forEach { status -> + status.flattenCurrencies().forEach { currencyStatus -> + if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach + val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach + map[address] = status.account + } + } + } + return map +} \ No newline at end of file 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 c5515de93e..e85b818e28 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 @@ -9,16 +9,12 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday -import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository -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 @@ -92,7 +88,10 @@ internal class TxHistoryModel @Inject constructor( ) .map { (accountLists, modeEnabled, wallets) -> TxHistoryLookupContext( - ownAccountByAddress = buildOwnAccountAddressMap(accountLists), + ownAccountByAddress = buildOwnAccountAddressMap( + lists = accountLists, + networkRawId = params.currency.network.id.rawId, + ), isAccountsModeEnabled = modeEnabled, walletInfoById = wallets.associate { wallet -> wallet.walletId to WalletInfo( @@ -151,23 +150,6 @@ internal class TxHistoryModel @Inject constructor( subscribeOnCurrencyStatusUpdates() } - private fun buildOwnAccountAddressMap(lists: List): Map { - val networkRawId = params.currency.network.id.rawId - val map = mutableMapOf() - lists.forEach { accountList -> - accountList.accountStatuses - .filterCryptoPortfolio() - .forEach { status: AccountStatus.CryptoPortfolio -> - status.flattenCurrencies().forEach { currencyStatus -> - if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach - val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach - map[address] = status.account - } - } - } - return map - } - private fun subscribeToUiItemChanges() { txHistoryListManager ?.uiItems diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt index 39ec21f76e..e5c7804469 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt @@ -18,6 +18,7 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme @@ -56,17 +57,19 @@ internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountB textAlign = TextAlign.Center, textDecoration = if (amountBlock.isFailed) TextDecoration.LineThrough else null, ) - SpacerH(4.dp) - Text( - text = amountBlock.fiatAmount.resolveReference(), - color = if (amountBlock.isFailed) { - TangemTheme.colors3.text.tertiary - } else { - TangemTheme.colors3.text.secondary - }, - style = TangemTheme.typography3.body.medium, - textAlign = TextAlign.Center, - ) + amountBlock.fiatAmount?.let { fiatAmount -> + SpacerH(4.dp) + Text( + text = fiatAmount.resolveReference(), + color = if (amountBlock.isFailed) { + TangemTheme.colors3.text.tertiary + } else { + TangemTheme.colors3.text.secondary + }, + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.Center, + ) + } } } @@ -82,20 +85,23 @@ private fun TxHistoryDetailsAmountBlockPreview() { ) { TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false)) TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = true)) + // No fiat — the fiat line is omitted entirely. + TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false, fiatAmount = null)) } } } -private fun previewAmountBlock(isFailed: Boolean) = TxHistoryDetailsUM.AmountBlockUM( - currencyIcon = CurrencyIconState.CoinIcon( - url = null, - fallbackResId = R.drawable.img_eth_22, - isGrayscale = false, - shouldShowCustomBadge = false, - ), - amount = stringReference("+ 350.31 USDT"), - fiatAmount = stringReference("$350.31"), - isFailed = isFailed, -) +private fun previewAmountBlock(isFailed: Boolean, fiatAmount: TextReference? = stringReference("$350.31")) = + TxHistoryDetailsUM.AmountBlockUM( + currencyIcon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_eth_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + amount = stringReference("+ 350.31 USDT"), + fiatAmount = fiatAmount, + isFailed = isFailed, + ) // endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt index 236d3ffeca..14ed97a642 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt @@ -58,8 +58,7 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi .padding(start = 16.dp, end = 16.dp), ) } else { - // TODO([REDACTED_TASK_KEY]): the converter cannot populate the swap legs yet (TxInfo exposes no two-leg / fiat / - // provider data). Until those fields land, fall back to the header-only placeholder. + // Safety fallback for a future express variant that yields no asset legs — render the header-only card. TwoAssetsPlaceholder(state = state) } // Express status plaque under the exchange block. The top gap is owned by the banner (inside its collapsing @@ -70,6 +69,13 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi .fillMaxWidth() .padding(horizontal = 16.dp), ) + // Network fee (and later rate) pulled from the matched on-chain leg; the block is skipped when [rows] is empty. + TxHistoryDetailsInfoRows( + rows = state.rows, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp), + ) } } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt index 9a9a742589..3341e7358b 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt @@ -2,11 +2,17 @@ package com.tangem.features.txhistory.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -20,6 +26,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM +import com.tangem.features.txhistory.impl.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -50,14 +57,27 @@ internal fun TxHistoryDetailsInfoRows(rows: ImmutableList, modifier: contentLead = TangemRowContentLead.Start, titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) }, valueSlot = { - Text( - text = row.value.resolveReference(), - color = TangemTheme.colors3.text.secondary, - style = TangemTheme.typography3.body.medium, - textAlign = TextAlign.End, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = row.value.resolveReference(), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + row.trailingIconRes?.let { iconRes -> + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = TangemTheme.colors3.text.secondary, + modifier = Modifier.size(20.dp), + ) + } + } }, ) } @@ -77,7 +97,11 @@ private fun TxHistoryDetailsInfoRowsPreview() { // Multiple rows — dividers between rows, none after the last TxHistoryDetailsInfoRows( rows = persistentListOf( - InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), + InfoRowUM( + label = stringReference("Provider"), + value = stringReference("Mercuryo"), + trailingIconRes = R.drawable.ic_arrow_top_right_24, + ), InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")), InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), ), diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt index 3c8eb60f34..766ffaba83 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,6 +52,7 @@ import com.tangem.core.ui.res.generated.icons.ic_success_20 import com.tangem.core.ui.res.generated.icons.ic_warning_20 import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity +import kotlinx.coroutines.delay // Animation timings in ms (ProtoPie spec). The status swap is two-phase: the old status fades out, then the new one // fades/slides in after ENTER_DELAY. Most steps run over the default duration; the trailing loader/glyph fades faster @@ -61,6 +63,9 @@ private const val GROW_MILLIS = 400 private const val ENTER_DELAY_MILLIS = DEFAULT_ANIMATION_MILLIS // phase 2 waits for the phase-1 fade-out to clear private const val SUBTITLE_DELAY_MILLIS = ENTER_DELAY_MILLIS + 100 // subtitle trails the title +/** How long the success terminal ("Confirmed") lingers before the plaque auto-collapses — it shows only as a transition. */ +private const val CONFIRMED_VISIBLE_MILLIS = 1_000L + private const val TITLE_SLIDE_FRACTION = 12 // in-progress/Success title slides in 1/12 width from the right private const val CONTENT_RISE_FRACTION = 2 // Warning/Error title floats up 1/2 height from below private const val ICON_ENTER_SCALE = 0.6f @@ -134,8 +139,31 @@ internal fun TxHistoryDetailsStatusBanner(state: StatusBannerUM?, modifier: Modi SideEffect { if (state != null) lastState.value = state } val content = state ?: lastState.value + // Auto-hide rules for the success terminal ("Confirmed"). It is the only [Severity.Success] state and must read as a + // *transition*, not a resting state: opening the details on an already-finished deal (no in-flight status was ever + // seen) shows nothing, and once it does appear it lingers only briefly before collapsing. Failure / verification + // terminals are not Success, so they stay put. + val seenNonSuccess = remember { mutableStateOf(false) } + SideEffect { if (state != null && state.severity != Severity.Success) seenNonSuccess.value = true } + + val isTerminalSuccess = state?.severity == Severity.Success + val confirmedDismissed = remember { mutableStateOf(false) } + LaunchedEffect(isTerminalSuccess) { + if (isTerminalSuccess && seenNonSuccess.value) { + delay(CONFIRMED_VISIBLE_MILLIS) + confirmedDismissed.value = true + } + } + + val isVisible = when { + state == null -> false + isTerminalSuccess && !seenNonSuccess.value -> false // opened already on the success terminal → never shown + isTerminalSuccess && confirmedDismissed.value -> false // "Confirmed" lingered long enough → collapse away + else -> true + } + AnimatedVisibility( - visible = state != null, + visible = isVisible, // Fade and size share one tween so alpha and height finish together (mismatched default springs leave a jerk). enter = fadeIn(tween(DEFAULT_ANIMATION_MILLIS)) + expandVertically(tween(DEFAULT_ANIMATION_MILLIS), expandFrom = Alignment.Top), diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt index f57cc7e849..35772b1847 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon @@ -58,7 +57,7 @@ internal fun TxHistoryDetailsTopNavigation( modifier: Modifier = Modifier, ) { TangemTopNavigation( - modifier = modifier.padding(top = 8.dp), + modifier = modifier, windowInsets = WindowInsets(0), blurBackground = false, startButton = { StatusActionIcon(iconRes = header.iconRes, status = header.status) }, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt index 62e9dc2a16..c69eebdaa2 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt @@ -112,10 +112,13 @@ private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) { ) }, endSlot = { - TangemCurrencyIcon( - state = asset.currencyIcon, - modifier = Modifier.size(40.dp), - ) + // The fiat leg of an onramp carries no icon (no CryptoCurrency, no country flag) — leave the slot empty. + asset.currencyIcon?.let { icon -> + TangemCurrencyIcon( + state = icon, + modifier = Modifier.size(40.dp), + ) + } }, ) } @@ -231,10 +234,11 @@ private fun TxHistoryDetailsTwoAssetsBlockPreview() { from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false), to = previewAsset(label = "You receive", amount = "+ 1,800.00 POL", isFaded = false), ) - // Unsettled swap — the "You receive" side is struck through until the funds arrive. + // Unsettled swap — the "You receive" side shows the estimated amount with a `~` until the funds arrive + // (struck through is reserved for the failed state). TxHistoryDetailsTwoAssetsBlock( from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false), - to = previewAsset(label = "You receive", amount = "1,800.00 POL", isFaded = true), + to = previewAsset(label = "You receive", amount = "~ 1,800.00 POL", isFaded = false), ) // Account -> another account (own-to-own transfer between two of the user's accounts). TxHistoryDetailsTwoAssetsBlock( diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index a8330706ac..505744835a 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -10,8 +10,12 @@ 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.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType 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.SdkAmount import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType import com.tangem.domain.tokens.model.Amount @@ -94,9 +98,12 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { ) @Test - fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() { + fun `GIVEN incoming confirmed external Transfer WHEN convert THEN header has down icon, confirmed status, received title`() { // Arrange - val tx = onChain(type = TransactionType.Transfer) + val tx = onChain( + type = TransactionType.Transfer, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) // Act val header = converter.convert(tx).header @@ -104,6 +111,64 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { // Assert assertThat(header.iconRes).isEqualTo(R.drawable.ic_arrow_down_24) assertThat(header.status).isEqualTo(TransactionItemUM.Content.Status.Confirmed) + assertThat(header.title).isEqualTo(resourceReference(R.string.common_received)) + } + + @Test + fun `GIVEN outgoing external Transfer WHEN convert THEN sent title`() { + // Arrange + val tx = onChain( + type = TransactionType.Transfer, + isOutgoing = true, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + + // Act + val header = converter.convert(tx).header + + // Assert + assertThat(header.title).isEqualTo(resourceReference(R.string.common_sent)) + } + + @Test + fun `GIVEN incoming Transfer from own address WHEN convert THEN transferred title`() { + // Arrange — the counterparty is one of the user's own deposit addresses. + val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( + currency = currency, + onCopyAddress = copiedAddresses::add, + ownAddresses = setOf(USER_ADDRESS), + ) + val tx = onChain( + type = TransactionType.Transfer, + isOutgoing = false, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + + // Act + val header = ownConverter.convert(tx).header + + // Assert + assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred)) + } + + @Test + fun `GIVEN outgoing Transfer to own address WHEN convert THEN transferred title`() { + // Arrange + val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( + currency = currency, + onCopyAddress = copiedAddresses::add, + ownAddresses = setOf(USER_ADDRESS), + ) + val tx = onChain( + type = TransactionType.Transfer, + isOutgoing = true, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + + // Act + val header = ownConverter.convert(tx).header + + // Assert assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred)) } @@ -238,6 +303,35 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { assertThat(copiedAddresses).containsExactly(USER_ADDRESS) } + @Test + fun `GIVEN tx with fee WHEN convert THEN single network-fee row`() { + // Arrange + val tx = onChain( + type = TransactionType.Transfer, + fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18), + ) + + // Act + val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows + + // Assert + assertThat(rows).hasSize(1) + assertThat(rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title)) + assertThat(rows.first().value.resolveString()).contains("ETH") + } + + @Test + fun `GIVEN tx without fee WHEN convert THEN no rows`() { + // Arrange + val tx = onChain(type = TransactionType.Transfer, fee = null) + + // Act + val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows + + // Assert + assertThat(rows).isEmpty() + } + // endregion // region Express (swap / onramp) @@ -253,7 +347,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { } @Test - fun `GIVEN in-progress express swap WHEN convert THEN info status banner with loader`() { + fun `GIVEN exchanging 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 @@ -262,12 +356,54 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { assertThat(banner).isEqualTo( TxHistoryDetailsUM.StatusBannerUM( severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info, - title = resourceReference(R.string.express_exchange_status_receiving_active), + title = resourceReference(R.string.express_exchange_status_exchanging_active), isLoading = true, ), ) } + @Test + fun `GIVEN verifying express swap WHEN convert THEN warning status banner with verification subtitle`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Verifying)) + val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning, + title = resourceReference(R.string.express_exchange_status_verifying), + subtitle = resourceReference(R.string.express_exchange_notification_verification_text), + isLoading = false, + ), + ) + } + + @Test + fun `GIVEN finished express swap WHEN convert THEN success status banner`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished)) + val banner = (swap 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 unknown express swap WHEN convert THEN no status banner`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Unknown)) + + // Assert — nothing to surface, the plaque is hidden. + assertThat((swap as TxHistoryDetailsUM.TwoAssets).statusBanner).isNull() + } + @Test fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() { // Act @@ -285,6 +421,131 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { ) } + @Test + fun `GIVEN in-progress express swap WHEN convert THEN from is minus and to is approx, neither faded`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.from?.amount?.resolveString()).startsWith("- ") + assertThat(result.from?.isFaded).isFalse() + // Receive amount is still an estimate while in flight: `~`, not `+`, and not struck through. + assertThat(result.to?.amount?.resolveString()).startsWith("~ ") + assertThat(result.to?.isFaded).isFalse() + // Counterparty (to) symbol comes from the resolved CryptoCurrency; the unresolved from leg falls back to network id. + assertThat(result.to?.currencyIcon).isNotNull() + assertThat(result.from?.currencyIcon).isNull() + } + + @Test + fun `GIVEN finished express swap WHEN convert THEN to is plus and neither leg faded`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.from?.amount?.resolveString()).startsWith("- ") + assertThat(result.to?.amount?.resolveString()).startsWith("+ ") + assertThat(result.from?.isFaded).isFalse() + assertThat(result.to?.isFaded).isFalse() + } + + @Test + fun `GIVEN failed express swap WHEN convert THEN both legs faded and signs dropped`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.from?.isFaded).isTrue() + assertThat(result.to?.isFaded).isTrue() + assertThat(result.from?.amount?.resolveString()).doesNotContain("-") + assertThat(result.to?.amount?.resolveString()).doesNotContain("+") + } + + @Test + fun `GIVEN express swap with matched on-chain leg WHEN convert THEN network-fee row from leg`() { + // Arrange + val leg = onChain( + type = TransactionType.Swap, + fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18), + ) + + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.rows).hasSize(1) + assertThat(result.rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title)) + } + + @Test + fun `GIVEN express swap with provider WHEN convert THEN provider row with its name and link icon`() { + // Act + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Mercuryo")), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.rows).hasSize(1) + val providerRow = result.rows.first() + assertThat(providerRow.label).isEqualTo(resourceReference(R.string.express_provider)) + assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo") + assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24) + } + + @Test + fun `GIVEN express swap with provider and on-chain leg WHEN convert THEN provider row precedes network-fee row`() { + // Arrange + val leg = onChain( + type = TransactionType.Swap, + fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18), + ) + + // Act + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg, provider = provider(name = "Changelly")), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.rows.map { it.label }).containsExactly( + resourceReference(R.string.express_provider), + resourceReference(R.string.common_network_fee_title), + ).inOrder() + } + + @Test + fun `GIVEN finished express onramp WHEN convert THEN paid fiat is unsigned and topped-up crypto is plus`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets + + // Assert + // "You paid" fiat carries no icon and no sign — the exact amount paid. + assertThat(result.from?.currencyIcon).isNull() + assertThat(result.from?.amount?.resolveString()).contains("SEK") + assertThat(result.from?.amount?.resolveString()).doesNotContain("-") + assertThat(result.from?.amount?.resolveString()).doesNotContain("+") + assertThat(result.from?.amount?.resolveString()).doesNotContain("~") + // Topped-up crypto leg is settled: `+`, with an icon. + assertThat(result.to?.currencyIcon).isNotNull() + assertThat(result.to?.amount?.resolveString()).startsWith("+ ") + assertThat(result.to?.isFaded).isFalse() + } + + @Test + fun `GIVEN in-progress express onramp WHEN convert THEN paid fiat is unsigned and top-up crypto is approx`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Sending)) as TxHistoryDetailsUM.TwoAssets + + // Assert + // "You paid" stays unsigned regardless of status. + assertThat(result.from?.amount?.resolveString()).doesNotContain("-") + assertThat(result.from?.amount?.resolveString()).doesNotContain("+") + assertThat(result.from?.amount?.resolveString()).doesNotContain("~") + assertThat(result.from?.isFaded).isFalse() + // Crypto to-be-received is an estimate while in flight: `~`, not struck through. + assertThat(result.to?.amount?.resolveString()).startsWith("~ ") + assertThat(result.to?.isFaded).isFalse() + } + @Test fun `GIVEN finished express onramp WHEN convert THEN TwoAssets with success banner`() { // Act @@ -295,12 +556,37 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { assertThat(result.statusBanner).isEqualTo( TxHistoryDetailsUM.StatusBannerUM( severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success, - title = resourceReference(R.string.express_exchange_status_exchanged), + title = resourceReference(R.string.express_exchange_status_bought), isLoading = false, ), ) } + @Test + fun `GIVEN verifying express onramp WHEN convert THEN warning status banner with verification subtitle`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Verifying)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.statusBanner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning, + title = resourceReference(R.string.express_exchange_status_verifying), + subtitle = resourceReference(R.string.express_exchange_notification_verification_text), + isLoading = false, + ), + ) + } + + @Test + fun `GIVEN unknown express onramp WHEN convert THEN no status banner`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Unknown)) as TxHistoryDetailsUM.TwoAssets + + // Assert — nothing to surface, the plaque is hidden. + assertThat(result.statusBanner).isNull() + } + // endregion private fun onChain( @@ -309,6 +595,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed, amount: BigDecimal = BigDecimal.ONE, interactionAddressType: TxInfo.InteractionAddressType? = null, + fee: SdkAmount? = null, ): OnChainTx.BSDK = OnChainTx.BSDK( TxInfo( txHash = TX_HASH, @@ -320,25 +607,49 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { status = status, type = type, amount = amount, + fee = fee, ), ) - private fun expressSwap(status: ExpressExchangeStatus): ExpressTx.Swap = ExpressTx.Swap( + private fun provider(name: String): ExpressProvider = ExpressProvider( + providerId = "provider-1", + name = name, + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + private fun expressSwap( + status: ExpressExchangeStatus, + isOutgoing: Boolean = true, + txInfo: OnChainTx? = null, + provider: ExpressProvider? = null, + ): ExpressTx.Swap = ExpressTx.Swap( tx = ExchangeTransaction( txId = "swap-1", status = status, createdAtMillis = TIMESTAMP, - provider = null, + provider = provider, payinHash = null, payoutHash = null, fromAsset = expressAsset(networkId = "ethereum", amount = BigDecimal("1.5"), decimals = 18), - toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.001"), decimals = 8), + toAsset = expressAsset( + networkId = "bitcoin", + amount = BigDecimal("0.001"), + decimals = 8, + cryptoCurrency = currency, + ), ), - isOutgoing = true, - txInfo = null, + isOutgoing = isOutgoing, + txInfo = txInfo, ) - private fun expressOnramp(status: ExpressOnrampStatus): ExpressTx.Onramp = ExpressTx.Onramp( + private fun expressOnramp( + status: ExpressOnrampStatus, + txInfo: OnChainTx? = null, + ): ExpressTx.Onramp = ExpressTx.Onramp( tx = OnrampTransaction( txId = "onramp-1", status = status, @@ -351,16 +662,27 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { decimals = 2, type = AmountType.FiatType(code = "SEK"), ), - toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.006"), decimals = 8), + toAsset = expressAsset( + networkId = "bitcoin", + amount = BigDecimal("0.006"), + decimals = 8, + cryptoCurrency = currency, + ), ), - txInfo = null, + txInfo = txInfo, ) - private fun expressAsset(networkId: String, amount: BigDecimal, decimals: Int): ExpressTransactionAsset = + private fun expressAsset( + networkId: String, + amount: BigDecimal, + decimals: Int, + cryptoCurrency: CryptoCurrency? = null, + ): ExpressTransactionAsset = ExpressTransactionAsset( id = ExpressAssetId(networkId = networkId, contractAddress = "0"), amount = amount, decimals = decimals, + cryptoCurrency = cryptoCurrency, ) private fun TextReference.resolveString(): String = (this as TextReference.Str).value