Updated on 2026-08-14
This commit is contained in:
parent
3503cadb5e
commit
b91ed6359c
12 changed files with 305 additions and 97 deletions
|
|
@ -53,6 +53,7 @@ internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input,
|
|||
createdAtMillis = parseIsoMillis(entity.createdAt),
|
||||
provider = value.provider,
|
||||
payoutHash = entity.payoutHash,
|
||||
payoutAddress = entity.payoutAddress,
|
||||
fromFiat = Amount(
|
||||
currencySymbol = entity.fromCurrencyCode,
|
||||
value = entity.fromAmount.toScaledBigDecimal(entity.fromPrecision),
|
||||
|
|
@ -89,6 +90,8 @@ private fun convertExchangeTransaction(value: ExpressSwapConverter.Input): Excha
|
|||
provider = value.provider,
|
||||
payinHash = entity.payinHash,
|
||||
payoutHash = entity.payoutHash,
|
||||
fromAddress = entity.fromAddress,
|
||||
payoutAddress = entity.payoutAddress,
|
||||
fromAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
|
||||
amount = entity.from.amount.toScaledBigDecimal(entity.from.decimals),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ package com.tangem.domain.express.models
|
|||
* @property provider The provider behind the deal; `null` if not resolved.
|
||||
* @property payinHash On-chain hash of the pay-in (from-side) leg, if known.
|
||||
* @property payoutHash On-chain hash of the payout (to-side) leg, if known.
|
||||
* @property fromAddress Address the `from` assets were taken from (the user's own source address); `null` when unknown
|
||||
* (very old app versions did not send it).
|
||||
* @property payoutAddress Address that received the `to` assets — the user's own address for a regular swap, an external
|
||||
* one for a send-and-swap; `null` when unknown.
|
||||
* @property fromAsset The asset sent.
|
||||
* @property toAsset The asset received.
|
||||
* @property externalTxUrl The provider's page for this deal (tracking / refund / KYC); `null` when the provider
|
||||
|
|
@ -23,6 +27,8 @@ data class ExchangeTransaction(
|
|||
val provider: ExpressProvider?,
|
||||
val payinHash: String?,
|
||||
val payoutHash: String?,
|
||||
val fromAddress: String?,
|
||||
val payoutAddress: String?,
|
||||
val fromAsset: ExpressTransactionAsset,
|
||||
val toAsset: ExpressTransactionAsset,
|
||||
val externalTxUrl: String? = null,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.tangem.domain.tokens.model.AmountType
|
|||
|
||||
* @property provider The provider behind the deal; `null` if not resolved.
|
||||
* @property payoutHash On-chain hash of the payout (received) leg, if known.
|
||||
* @property payoutAddress Address that received the crypto (the user's own address); `null` when unknown.
|
||||
* @property fromFiat The fiat paid.
|
||||
* @property toAsset The crypto asset received.
|
||||
* @property country The country the onramp was made from; `null` if not resolved.
|
||||
|
|
@ -26,6 +27,7 @@ data class OnrampTransaction(
|
|||
val createdAtMillis: Long,
|
||||
val provider: ExpressProvider?,
|
||||
val payoutHash: String?,
|
||||
val payoutAddress: String?,
|
||||
/** The [Amount.type] is [AmountType.FiatType] . */
|
||||
val fromFiat: Amount,
|
||||
val toAsset: ExpressTransactionAsset,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue