From b91ed6359c336532b2c9bebc439d76c47fda03d4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 17:22:09 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../converter/ExpressTxHistoryConverter.kt | 3 + .../express/models/ExchangeTransaction.kt | 6 ++ .../express/models/OnrampTransaction.kt | 2 + ...istoryInfoToTxHistoryDetailsUMConverter.kt | 76 ++++++++++++++++--- ...HistoryItemToTransactionItemUMConverter.kt | 34 ++++++--- .../txhistory/entity/TxHistoryDetailsUM.kt | 20 +++-- .../txhistory/model/TxHistoryDetailsModel.kt | 17 +---- .../txhistory/model/TxHistoryLookupContext.kt | 66 ++++++++++++---- .../txhistory/model/TxHistoryModel.kt | 34 +-------- .../model/TxHistoryOwnerLookupProducer.kt | 57 ++++++++++++++ .../ui/TxHistoryDetailsTwoAssetsBlock.kt | 46 ++++++++--- ...ryInfoToTxHistoryDetailsUMConverterTest.kt | 41 ++++++++++ 12 files changed, 305 insertions(+), 97 deletions(-) create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryOwnerLookupProducer.kt 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 8d001e036c..7f2e4c4817 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 @@ -53,6 +53,7 @@ internal class ExpressOnrampConverter : Converter Unit, private val onGoToProvider: (String) -> Unit, - private val ownAddresses: Set = emptySet(), + private val lookup: TxHistoryLookupContext = TxHistoryLookupContext( + ownAccountByNetwork = emptyMap(), + isAccountsModeEnabled = false, + walletInfoById = emptyMap(), + ), ) : Converter { private val iconStateConverter = CryptoCurrencyToIconStateConverter() private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter() private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter() + /** Own deposit addresses on the viewed currency's network — drives the on-chain own-vs-external transfer title. */ + private val ownAddresses: Set = + lookup.ownAccountByNetwork[currency.network.id.rawId]?.keys.orEmpty() + override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) { is OnChainTx.BSDK -> convertOnChain(value.txInfo) is ExpressTx.Swap -> convertExpressSwap(value) @@ -93,12 +107,13 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( ) /** - * Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced — built from - * the `User` interaction address (the same source the history list uses for its external-address subtitle). + * Counterparty card ("Recipient" / "From"). Only the external-address avatar is produced — built from the `User` + * interaction address (the same source the history list uses for its external-address subtitle); a counterparty that + * is not a plain external `User` address yields no card (`null`). * - * The own-account / own-wallet avatars require the address->owner lookup the list assembles in - * `TxHistoryLookupContext`; wiring that into the detail model is a follow-up, so for now a counterparty that is not - * a plain external `User` address yields no card (`null`). + * The [lookup] needed to resolve an own-account / own-wallet avatar here is already available (it drives the + * swap/onramp leg owners), but applying it to the single-asset counterparty card is intentionally out of scope for + * now — a follow-up. */ private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? { val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null @@ -144,6 +159,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( */ private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets { val status = exchangeStatusConverter.convert(swap.tx.status) + val fromOwner = resolveLegOwner(swap.tx.fromAddress, swap.tx.fromAsset.cryptoCurrency) + val toOwner = resolveLegOwner(swap.tx.payoutAddress, swap.tx.toAsset.cryptoCurrency) return TxHistoryDetailsUM.TwoAssets( header = TxHistoryDetailsUM.HeaderUM( iconRes = R.drawable.ic_exchange_vertical_24, @@ -152,12 +169,14 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( subtitle = headerSubtitle(swap.timestampMillis), ), from = swap.tx.fromAsset.toAssetUM( - label = resourceReference(R.string.swapping_from_title_v2), + label = ownerLabel(fromOwner, fallback = R.string.swapping_from_title_v2, owned = R.string.common_from), + owner = fromOwner, sign = status.outgoingSign(), isFaded = status is Status.Failed, ), to = swap.tx.toAsset.toAssetUM( - label = resourceReference(R.string.swapping_to_title), + label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to), + owner = toOwner, sign = status.incomingSign(), isFaded = status is Status.Failed, ), @@ -169,6 +188,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets { val status = onrampStatusConverter.convert(onramp.tx.status) + val toOwner = resolveLegOwner(onramp.tx.payoutAddress, onramp.tx.toAsset.cryptoCurrency) return TxHistoryDetailsUM.TwoAssets( header = TxHistoryDetailsUM.HeaderUM( iconRes = R.drawable.ic_tangem_card_24, @@ -180,11 +200,13 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( subtitle = headerSubtitle(onramp.timestampMillis), ), from = onramp.tx.fromFiat.toFiatAssetUM( - label = resourceReference(R.string.swapping_from_title_v2), + // The fiat side was paid from a card, not a portfolio address — no owner to resolve. + label = resourceReference(R.string.tx_history_you_paid), isFaded = status is Status.Failed, ), to = onramp.tx.toAsset.toAssetUM( - label = resourceReference(R.string.swapping_to_title), + label = ownerLabel(toOwner, fallback = R.string.swapping_to_title, owned = R.string.common_to), + owner = toOwner, sign = status.incomingSign(), isFaded = status is Status.Failed, ), @@ -194,6 +216,37 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( ) } + /** + * Resolves a swap/onramp leg's [address] (on the leg currency's network) to the owner shown under the amount: + * the user's own account / wallet, or the external [TxHistoryDetailsUM.AssetOwnerUM.Address] (e.g. a send-and-swap + * payout). `null` when there is no address to resolve (e.g. the very-old-version missing `fromAddress`, onramp fiat). + */ + private fun resolveLegOwner(address: String?, legCurrency: CryptoCurrency?): TxHistoryDetailsUM.AssetOwnerUM? { + if (address == null) return null + return when (val resolved = lookup.resolveOwner(address, legCurrency?.network?.id?.rawId)) { + is ResolvedOwner.OwnAccount -> TxHistoryDetailsUM.AssetOwnerUM.Account( + name = resolved.account.accountName.toUM().value, + iconResId = resolved.account.icon.value.getResId(), + backgroundColor = resolved.account.icon.color.getUiColor(), + ) + is ResolvedOwner.OwnWallet -> TxHistoryDetailsUM.AssetOwnerUM.Wallet( + name = stringReference(resolved.walletInfo.name), + deviceIconUM = resolved.walletInfo.deviceIconUM, + ) + is ResolvedOwner.External -> TxHistoryDetailsUM.AssetOwnerUM.Address( + name = stringReference(resolved.address.toBriefAddressFormat()), + rawAddress = resolved.address, + ) + } + } + + /** Leg caption: the direction-only [fallback] ("You send" / "You receive") without an owner, "From" / "To" with one. */ + private fun ownerLabel( + owner: TxHistoryDetailsUM.AssetOwnerUM?, + @StringRes fallback: Int, + @StringRes owned: Int, + ): TextReference = resourceReference(if (owner != null) owned else fallback) + /** Opens the deal's provider page on tap; `null` when the deal has no provider link. */ private fun ExpressTx.providerClick(): (() -> Unit)? = externalTxUrl?.let { url -> { onGoToProvider(url) } } @@ -212,6 +265,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( */ private fun ExpressTransactionAsset.toAssetUM( label: TextReference, + owner: TxHistoryDetailsUM.AssetOwnerUM?, sign: String, isFaded: Boolean, ): TxHistoryDetailsUM.AssetUM { @@ -223,7 +277,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( ) }.trim() return TxHistoryDetailsUM.AssetUM( label = label, - owner = null, + owner = owner, amount = stringReference((sign + formatted).trim()), currencyIcon = cryptoCurrency?.let(iconStateConverter::convert), isFaded = isFaded, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt index afd4459fd5..35630275e2 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt @@ -14,11 +14,14 @@ 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.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType import com.tangem.features.txhistory.impl.R import com.tangem.features.txhistory.converter.TxHistoryStatusPillConverter.Input as PillInput +import com.tangem.features.txhistory.model.ResolvedOwner import com.tangem.features.txhistory.model.TxHistoryLookupContext +import com.tangem.features.txhistory.model.resolveOwner import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter @@ -98,7 +101,14 @@ internal class TxHistoryItemToTransactionItemUMConverter( private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content { val counterpartyAddress = (tx.interactionAddressType as? TxInfo.InteractionAddressType.User)?.address val direction = if (tx.isOutgoing) ContentSubtitle.Direction.TO else ContentSubtitle.Direction.FROM - val ownSubtitle = counterpartyAddress?.let { resolveOwnSubtitle(lookupContext, it, direction) } + val ownSubtitle = counterpartyAddress?.let { address -> + resolveOwnSubtitle( + lookupContext = lookupContext, + networkRawId = currency.network.id.rawId, + address = address, + direction = direction, + ) + } val title = when { ownSubtitle != null -> tx.statusAwareTitle(R.string.common_transfer, R.string.common_transferred) @@ -261,25 +271,25 @@ private fun TxInfo.formatContentAmount(currency: CryptoCurrency): String { private fun resolveOwnSubtitle( lookupContext: TxHistoryLookupContext?, + networkRawId: Network.RawID, address: String, direction: ContentSubtitle.Direction, ): ContentSubtitle? { val ctx = lookupContext ?: return null - val account = ctx.ownAccountByAddress[address] ?: return null - return if (ctx.isAccountsModeEnabled) { - ContentSubtitle.OwnAccount( + return when (val resolved = ctx.resolveOwner(address, networkRawId)) { + is ResolvedOwner.OwnAccount -> ContentSubtitle.OwnAccount( direction = direction, - accountName = account.accountName.toUM().value, - iconResId = account.icon.value.getResId(), - iconBackgroundColor = account.icon.color.getUiColor(), + accountName = resolved.account.accountName.toUM().value, + iconResId = resolved.account.icon.value.getResId(), + iconBackgroundColor = resolved.account.icon.color.getUiColor(), ) - } else { - val walletInfo = ctx.walletInfoById[account.accountId.userWalletId] ?: return null - ContentSubtitle.OwnWallet( + is ResolvedOwner.OwnWallet -> ContentSubtitle.OwnWallet( direction = direction, - walletName = walletInfo.name, - deviceIconUM = walletInfo.deviceIconUM, + walletName = resolved.walletInfo.name, + deviceIconUM = resolved.walletInfo.deviceIconUM, ) + // External counterparty: caller falls back to ContentSubtitle.ExternalAddress. + is ResolvedOwner.External -> null } } 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 179cd1d3c6..6d80635f1a 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 @@ -102,9 +102,9 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { ) /** - * Counterparty rendered inline in an [AssetUM.label] when a swap leg resolves to one of the user's own portfolios. - * Carries the [name] plus a kind-specific 16dp decoration. Only own account / own wallet are decorated here (no - * address case, unlike the single-asset [CounterpartyAvatar]). + * Counterparty rendered inline in an [AssetUM.label] of a swap leg. Carries the [name] plus a kind-specific 16dp + * decoration: an own account / own wallet when the leg's address resolves to one of the user's portfolios, or an + * external blockchain [Address] (e.g. a send-and-swap payout to a non-user address). */ @Immutable sealed interface AssetOwnerUM { @@ -123,6 +123,15 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { override val name: TextReference, val deviceIconUM: DeviceIconUM, ) : AssetOwnerUM + + /** + * External blockchain address — the [name] is the brief address, decorated with an identicon generated from + * [rawAddress], shown **before** the [name]. + */ + data class Address( + override val name: TextReference, + val rawAddress: String, + ) : AssetOwnerUM } /** @@ -164,9 +173,8 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * * The layout is identical across counterparty kinds; the only variance is the [avatar] (see [CounterpartyAvatar]) * and whether copy is offered. Only the [CounterpartyAvatar.Address] kind is currently produced by - * [com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet - * avatars are populated in a follow-up, once the detail model assembles the same address->owner lookup the list - * uses (`TxHistoryLookupContext`). + * [com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter]; resolving the own-account / + * own-wallet avatars here (the `TxHistoryLookupContext` is already wired for the swap/onramp legs) is a follow-up. * * @property label Section label above the counterparty: "Recipient" (outgoing) / "From" (incoming). * @property title Counterparty value: brief address / account name / wallet name. 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 9837280310..3486e8041f 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -6,18 +6,14 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener 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 import javax.inject.Inject @@ -27,26 +23,21 @@ internal class TxHistoryDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val clipboardManager: ClipboardManager, private val urlOpener: UrlOpener, - multiAccountStatusListSupplier: MultiAccountStatusListSupplier, + ownerLookupProducer: TxHistoryOwnerLookupProducer, paramsContainer: ParamsContainer, ) : Model() { private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() - /** 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 = combine( params.txHistoryInfo, - ownAddressesFlow, - ) { txInfo, ownAddresses -> + ownerLookupProducer(), + ) { txInfo, lookup -> TxHistoryInfoToTxHistoryDetailsUMConverter( currency = params.currency, onCopyAddress = ::onCopyAddress, onGoToProvider = urlOpener::openUrl, - ownAddresses = ownAddresses, + lookup = lookup, ).convert(txInfo) } .flowOn(dispatchers.default) 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 f8e90d6a5b..21d971817c 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 @@ -8,14 +8,15 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId /** - * Per-page lookup context for the tx-history converter. + * Lookup context for resolving a transfer/swap counterparty to one of the user's own portfolios, shared by the history + * list and the details screen (both assembled by [TxHistoryOwnerLookupProducer]). * - * - [ownAccountByAddress] / [walletInfoById] — address-keyed lookups for resolving counterparty owners - * in transfer subtitles ("to / from MY account / wallet"). + * - [ownAccountByNetwork] / [walletInfoById] — `address -> account` maps per network (a swap's legs can sit on + * different networks) plus per-wallet display info, used to render "to / from MY account / wallet". * - [isAccountsModeEnabled] — toggles whether a resolved owner is rendered as account or wallet. */ internal data class TxHistoryLookupContext( - val ownAccountByAddress: Map, + val ownAccountByNetwork: Map>, val isAccountsModeEnabled: Boolean, val walletInfoById: Map, ) @@ -23,25 +24,62 @@ internal data class TxHistoryLookupContext( 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. + * The owner that a transfer counterparty resolves to, before it is mapped to a UI model. Shared by the history list + * (subtitle) and the details screen (leg owner) so both apply the same precedence: + * account (accounts mode on) → wallet (accounts mode off) → external address. */ -internal fun buildOwnAccountAddressMap( +internal sealed interface ResolvedOwner { + data class OwnAccount(val account: Account.CryptoPortfolio) : ResolvedOwner + data class OwnWallet(val walletInfo: WalletInfo) : ResolvedOwner + data class External(val address: String) : ResolvedOwner +} + +/** + * Resolves a counterparty [address] on the network [networkRawId] to a [ResolvedOwner]: the owning account in accounts + * mode, otherwise the owning wallet, falling back to the external address when it is none of the user's (or accounts + * mode is off and the wallet info is missing). + * + * [networkRawId] `null` (an unresolved express leg whose `cryptoCurrency` is missing) falls back to a cross-network + * lookup: the address is matched across every network and accepted only when it maps to exactly one account (EVM-family + * addresses repeat across chains but stay within one account; a tie across distinct accounts stays external). + */ +internal fun TxHistoryLookupContext.resolveOwner(address: String, networkRawId: Network.RawID?): ResolvedOwner { + val account = if (networkRawId != null) { + ownAccountByNetwork[networkRawId]?.get(address) + } else { + ownAccountByNetwork.values + .mapNotNull { it[address] } + .distinctBy { it.accountId } + .singleOrNull() + } + return when { + account == null -> ResolvedOwner.External(address) + isAccountsModeEnabled -> ResolvedOwner.OwnAccount(account) + else -> walletInfoById[account.accountId.userWalletId] + ?.let { ResolvedOwner.OwnWallet(it) } + ?: ResolvedOwner.External(address) + } +} + +/** + * Flattens every crypto-portfolio account of every wallet into `address -> account` maps keyed by [Network.RawID] + * (a swap's two legs can sit on different networks). Used to decide whether a transfer counterparty is one of the + * user's own accounts/wallets. + */ +internal fun buildOwnAccountAddressMapAllNetworks( lists: List, - networkRawId: Network.RawID, -): Map { - val map = mutableMapOf() +): 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 + val rawId = currencyStatus.currency.network.id.rawId + map.getOrPut(rawId) { mutableMapOf() }[address] = status.account } } } - return map + return map.mapValues { (_, addresses) -> addresses.toMap() } } \ 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 e85b818e28..b890e9a019 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 @@ -2,19 +2,15 @@ package com.tangem.features.txhistory.model import androidx.compose.runtime.Stable import arrow.core.Option -import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday -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.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.TxInfo import com.tangem.domain.txhistory.model.ExpressTx @@ -28,7 +24,6 @@ import com.tangem.domain.txhistory.models.TxHistoryStateError import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase -import com.tangem.domain.wallets.usecase.GetWalletIconUseCase import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.converter.ExpressTxToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter @@ -60,8 +55,6 @@ internal class TxHistoryModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, - private val getWalletIconUseCase: GetWalletIconUseCase, - private val walletIconUMConverter: WalletIconUMConverter, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val urlOpener: UrlOpener, private val txHistoryUpdateListener: TxHistoryUpdateListener, @@ -72,36 +65,13 @@ internal class TxHistoryModel @Inject constructor( private val appTxHistoryFetcher: AppTxHistoryFetcher, repository: TxHistoryRepositoryV2, paramsContainer: ParamsContainer, - multiAccountStatusListSupplier: MultiAccountStatusListSupplier, - isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - userWalletsListRepository: UserWalletsListRepository, + ownerLookupProducer: TxHistoryOwnerLookupProducer, ) : Model(), TxHistoryUiActions { private val params: TxHistoryComponent.Params = paramsContainer.require() private val lookupDataFlow: Flow = if (designFeatureToggles.isRedesignEnabled) { - combine( - flow = multiAccountStatusListSupplier(), - flow2 = isAccountsModeEnabledUseCase(), - flow3 = userWalletsListRepository.userWallets.filterNotNull(), - transform = ::Triple, - ) - .map { (accountLists, modeEnabled, wallets) -> - TxHistoryLookupContext( - ownAccountByAddress = buildOwnAccountAddressMap( - lists = accountLists, - networkRawId = params.currency.network.id.rawId, - ), - isAccountsModeEnabled = modeEnabled, - walletInfoById = wallets.associate { wallet -> - wallet.walletId to WalletInfo( - name = wallet.name, - deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(wallet)), - ) - }, - ) - } - .distinctUntilChanged() + ownerLookupProducer() .flowOn(dispatchers.default) .shareIn(modelScope, SharingStarted.WhileSubscribed(), replay = 1) } else { diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryOwnerLookupProducer.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryOwnerLookupProducer.kt new file mode 100644 index 0000000000..ef2b582b03 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryOwnerLookupProducer.kt @@ -0,0 +1,57 @@ +package com.tangem.features.txhistory.model + +import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.wallets.usecase.GetWalletIconUseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Single source of the [TxHistoryLookupContext] used by both the history list and the details screen: combines the + * account statuses (own-address map per network), the accounts-mode toggle and the wallet display info. Callers apply + * their own dispatcher / sharing — this only produces the cold combined flow. + */ +internal class TxHistoryOwnerLookupProducer @Inject constructor( + private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val userWalletsListRepository: UserWalletsListRepository, + private val getWalletIconUseCase: GetWalletIconUseCase, + private val walletIconUMConverter: WalletIconUMConverter, +) { + + operator fun invoke(): Flow { + val ownAccountByNetwork = multiAccountStatusListSupplier() + .map(::buildOwnAccountAddressMapAllNetworks) + .distinctUntilChanged() + + val walletInfoById = userWalletsListRepository.userWallets + .filterNotNull() + .map { wallets -> + wallets.associate { wallet -> + wallet.walletId to WalletInfo( + name = wallet.name, + deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(wallet)), + ) + } + } + .distinctUntilChanged() + + return combine( + ownAccountByNetwork, + isAccountsModeEnabledUseCase(), + walletInfoById, + ) { accounts, modeEnabled, walletInfo -> + TxHistoryLookupContext( + ownAccountByNetwork = accounts, + isAccountsModeEnabled = modeEnabled, + walletInfoById = walletInfo, + ) + } + } +} \ No newline at end of file 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 c69eebdaa2..1eaef4b9f7 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 @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon +import com.tangem.core.ui.components.icons.identicon.IdentIcon import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.ds.image.TangemDeviceIcon import com.tangem.core.ui.ds2.row.TangemRow @@ -45,9 +46,9 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetOwnerUM import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetUM /** - * Two-asset ("exchange") block of the details card, used by Swap (and later Onramp): one `bg.tertiary` rounded cell - * with the [from] ("You sent") side over the [to] ("You receive") side, split by an inset dashed divider with a - * centered down-arrow masking the line. Each side is a [TangemRow]: label over the signed amount, avatar trailing. + * Two-asset ("exchange") block of the details card, used by Swap and Onramp: one `bg.tertiary` rounded cell + * with the [from] side over the [to] side, split by an inset dashed divider with a centered down-arrow masking the + * line. Each side is a [TangemRow]: label (with an optional owner) over the signed amount, currency icon trailing. * * [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1265-87546) * @@ -124,9 +125,9 @@ private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) { } /** - * Caption label above a leg amount. Renders the [label] prefix ("You sent" / "You receive", or "From" / "To" when an - * [owner] is present) and, for a resolved [owner], its inline 16dp decoration in the Figma order — the account avatar - * leads its name, the wallet key-card icon trails its name. + * Caption label above a leg amount. Renders the [label] prefix ("You sent" / "You receive" / "You paid", or "From" / + * "To" when an [owner] is present) and, for a resolved [owner], its inline 16dp decoration in the Figma order — the + * account avatar and the external-address identicon lead their name, the wallet key-card icon trails its name. */ @Composable private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modifier: Modifier = Modifier) { @@ -137,7 +138,9 @@ private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modif ) { LabelText(text = label) when (owner) { - is AssetOwnerUM.Account -> { + is AssetOwnerUM.Account, + is AssetOwnerUM.Address, + -> { AssetOwnerIcon(owner = owner) LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false)) } @@ -162,7 +165,7 @@ private fun LabelText(text: TextReference, modifier: Modifier = Modifier) { ) } -/** 16dp inline owner decoration: the account glyph over its color, or the wallet device card. */ +/** 16dp inline owner decoration: the account glyph over its color, the wallet device card, or an address identicon. */ @Composable private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) { val iconModifier = modifier.size(16.dp) @@ -186,6 +189,10 @@ private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) { state = owner.deviceIconUM, modifier = iconModifier, ) + is AssetOwnerUM.Address -> IdentIcon( + address = owner.rawAddress, + modifier = iconModifier.clip(CircleShape), + ) } } @@ -217,7 +224,7 @@ private fun DashedDivider(modifier: Modifier = Modifier) { // region Preview -@Suppress("MagicNumber") +@Suppress("MagicNumber", "LongMethod") @Preview(name = "Light", showBackground = true, widthDp = 360) @Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360) @Composable @@ -284,6 +291,27 @@ private fun TxHistoryDetailsTwoAssetsBlockPreview() { ), ), ) + // Send-and-swap — the payout went to an external (non-user) address. + TxHistoryDetailsTwoAssetsBlock( + from = previewAsset( + label = "From", + amount = "- 390 USDT", + isFaded = false, + owner = AssetOwnerUM.Wallet( + name = stringReference("Tangem 2.0"), + deviceIconUM = DeviceIconUM.Card(mainColor = Color(0xFF1E1E1E), secondColor = null), + ), + ), + to = previewAsset( + label = "To", + amount = "+ 1,800.00 POL", + isFaded = false, + owner = AssetOwnerUM.Address( + name = stringReference("33Bd3…a21412B"), + rawAddress = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359", + ), + ), + ) } } } 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 5be02de5b6..5e56147711 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 @@ -53,6 +53,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { // The express payout leg: a real Bitcoin coin so the resolved symbol (BTC) matches the "bitcoin" network id. private val bitcoin = mockCurrencyFactory.bitcoin private val ownAccount: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 1, name = "Family") + private val secondAccount: Account.CryptoPortfolio = MockAccounts.createAccount(derivationIndex = 2, name = "Savings") private val copiedAddresses = mutableListOf() private val openedUrls = mutableListOf() private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( @@ -834,6 +835,46 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { assertThat(result.to?.label).isEqualTo(resourceReference(R.string.common_to)) } + @Test + fun `GIVEN leg with unresolved currency but own address WHEN convert THEN owner resolved cross-network`() { + // Arrange — from leg has no cryptoCurrency (null network), yet its address is owned on exactly one network. + val swap = expressSwap( + status = ExpressExchangeStatus.Finished, + fromAddress = FROM_ADDRESS, + fromCurrency = null, + ) + val lookup = lookupOf(currency.network.id.rawId to mapOf(FROM_ADDRESS to ownAccount)) + + // Act + val result = ownConverter(lookup).convert(swap) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.from?.owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Account::class.java) + assertThat(result.from?.label).isEqualTo(resourceReference(R.string.common_from)) + } + + @Test + fun `GIVEN leg with unresolved currency and address on two distinct accounts WHEN convert THEN stays external`() { + // Arrange — null network forces a cross-network lookup; the same address maps to two different accounts. + val swap = expressSwap( + status = ExpressExchangeStatus.Finished, + fromAddress = FROM_ADDRESS, + fromCurrency = null, + ) + val lookup = lookupOf( + currency.network.id.rawId to mapOf(FROM_ADDRESS to ownAccount), + bitcoin.network.id.rawId to mapOf(FROM_ADDRESS to secondAccount), + ) + + // Act + val result = ownConverter(lookup).convert(swap) as TxHistoryDetailsUM.TwoAssets + + // Assert — ambiguous, so it falls back to the external address rather than guessing an owner. + val owner = result.from?.owner + assertThat(owner).isInstanceOf(TxHistoryDetailsUM.AssetOwnerUM.Address::class.java) + assertThat((owner as TxHistoryDetailsUM.AssetOwnerUM.Address).rawAddress).isEqualTo(FROM_ADDRESS) + } + // endregion private fun onChain(