Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 17:22:09 +03:00
parent 3503cadb5e
commit b91ed6359c
12 changed files with 305 additions and 97 deletions

View file

@ -1,6 +1,9 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.common.ui.account.getResId
import com.tangem.common.ui.account.getUiColor
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.extensions.TextReference
@ -28,6 +31,9 @@ import com.tangem.domain.txhistory.model.TxHistoryInfo
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
import com.tangem.features.txhistory.impl.R
import com.tangem.features.txhistory.model.ResolvedOwner
import com.tangem.features.txhistory.model.TxHistoryLookupContext
import com.tangem.features.txhistory.model.resolveOwner
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
@ -51,13 +57,21 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter(
private val currency: CryptoCurrency,
private val onCopyAddress: (String) -> Unit,
private val onGoToProvider: (String) -> Unit,
private val ownAddresses: Set<String> = emptySet(),
private val lookup: TxHistoryLookupContext = TxHistoryLookupContext(
ownAccountByNetwork = emptyMap(),
isAccountsModeEnabled = false,
walletInfoById = emptyMap(),
),
) : Converter<TxHistoryInfo, TxHistoryDetailsUM> {
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<String> =
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,

View file

@ -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
}
}

View file

@ -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.

View file

@ -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<Set<String>> = multiAccountStatusListSupplier()
.map { lists -> buildOwnAccountAddressMap(lists, params.currency.network.id.rawId).keys }
.distinctUntilChanged()
val uiState: StateFlow<TxHistoryDetailsUM?> = 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)

View file

@ -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<String, Account.CryptoPortfolio>,
val ownAccountByNetwork: Map<Network.RawID, Map<String, Account.CryptoPortfolio>>,
val isAccountsModeEnabled: Boolean,
val walletInfoById: Map<UserWalletId, WalletInfo>,
)
@ -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<AccountStatusList>,
networkRawId: Network.RawID,
): Map<String, Account.CryptoPortfolio> {
val map = mutableMapOf<String, Account.CryptoPortfolio>()
): Map<Network.RawID, Map<String, Account.CryptoPortfolio>> {
val map = mutableMapOf<Network.RawID, MutableMap<String, Account.CryptoPortfolio>>()
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() }
}

View file

@ -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<TxHistoryLookupContext> = 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 {

View file

@ -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<TxHistoryLookupContext> {
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,
)
}
}
}

View file

@ -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",
),
),
)
}
}
}

View file

@ -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<String>()
private val openedUrls = mutableListOf<String>()
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(