Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-12 10:48:13 +03:00
parent b7c264b448
commit 6e92e35a38
13 changed files with 1060 additions and 49 deletions

View file

@ -77,6 +77,13 @@ object DateTimeFormatters {
getBestFormatterBySkeleton("MMM dd")
}
/**
* Example: "Jun 1, 2020", "1 Jun 2020"
*/
val dateMMMdYYYY: DateTimeFormatter by lazy {
getBestFormatterBySkeleton("MMM d, yyyy")
}
/**
* Example: "2020"
*/

View file

@ -60,6 +60,7 @@ dependencies {
implementation(deps.decompose.ext.compose)
/* Tests */
testImplementation(projects.common.test)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)

View file

@ -5,14 +5,8 @@ import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.model.TxHistoryDetailsModel
import com.tangem.features.txhistory.ui.TxHistoryDetailsContent
import com.tangem.features.txhistory.ui.TxHistoryDetailsModalBottomSheetContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -32,17 +26,7 @@ internal class DefaultTxHistoryDetailsComponent @AssistedInject constructor(
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
TangemModalBottomSheet<TxHistoryDetailsUM>(
config = TangemBottomSheetConfig(
isShown = state != null,
onDismissRequest = ::dismiss,
content = state ?: TangemBottomSheetConfigContent.Empty,
),
title = {
TangemModalBottomSheetTitle(endIconRes = R.drawable.ic_close_24, onEndClick = ::dismiss)
},
content = { um -> TxHistoryDetailsContent(state = um) },
)
TxHistoryDetailsModalBottomSheetContent(state = state, onDismiss = ::dismiss)
}
@AssistedFactory

View file

@ -1,9 +1,25 @@
package com.tangem.features.txhistory.converter
import androidx.annotation.StringRes
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.impl.R
import com.tangem.utils.StringsSigns
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import com.tangem.utils.toBriefAddressFormat
import org.joda.time.DateTime
/**
* Converts a [TxInfo] to a [TxHistoryDetailsUM] for the in-app transaction details card.
@ -13,18 +29,114 @@ import com.tangem.utils.converter.Converter
* - [TransactionType.Swap] (and onramp once it lands in `TxInfo`) -> [TxHistoryDetailsUM.TwoAssets]
* - everything else -> [TxHistoryDetailsUM.SingleAsset]
*/
internal class TxInfoToTxHistoryDetailsUMConverter : Converter<TxInfo, TxHistoryDetailsUM> {
internal class TxInfoToTxHistoryDetailsUMConverter(
private val currency: CryptoCurrency,
private val onCopyAddress: (String) -> Unit,
) : Converter<TxInfo, TxHistoryDetailsUM> {
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) {
is TransactionType.Swap -> twoAssets(value)
else -> singleAsset(value)
is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(header = value.toHeaderUM())
else -> TxHistoryDetailsUM.SingleAsset(
header = value.toHeaderUM(),
amountBlock = value.toAmountBlockUM(),
counterparty = value.toCounterpartyUM(),
// TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo.
rows = emptyList(),
)
}
private fun singleAsset(tx: TxInfo): TxHistoryDetailsUM.SingleAsset = TxHistoryDetailsUM.SingleAsset(
title = tx.type.toString(),
private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM(
iconRes = headerIcon(),
status = status.toUiStatus(),
title = headerTitle(),
subtitle = headerSubtitle(),
)
private fun twoAssets(tx: TxInfo): TxHistoryDetailsUM.TwoAssets = TxHistoryDetailsUM.TwoAssets(
title = tx.type.toString(),
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),
// TODO: TxInfo has no fiat amount yet — placeholder until the fiat field is added to TxInfo.
fiatAmount = stringReference("\$0.00"),
isFailed = status is TxInfo.TransactionStatus.Failed,
)
}
/**
* 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).
*
* The own-account / own-wallet avatars ([TxHistoryDetailsUM.CounterpartyAvatar.Account] / `Wallet`) 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`).
*/
private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? {
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
return TxHistoryDetailsUM.CounterpartyUM(
label = counterpartyLabel(),
title = stringReference(address.toBriefAddressFormat()),
avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address),
onCopyClick = { onCopyAddress(address) },
)
}
/** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */
private fun TxInfo.counterpartyLabel(): TextReference =
if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from)
}
// region Amount building helpers
/**
* Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+`
* otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) the UI then only
* strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed].
*/
private fun TxInfo.signedAmount(currency: CryptoCurrency): String {
val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) }
val prefix = when {
status is TxInfo.TransactionStatus.Failed -> ""
amount.isZero() -> ""
isOutgoing -> "${StringsSigns.MINUS} "
else -> "${StringsSigns.PLUS} "
}
return (prefix + formatted).trim()
}
// endregion
// region Header building helpers
/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */
private fun TxInfo.headerIcon(): Int = when (type) {
is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24
else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
}
private fun TxInfo.headerTitle(): TextReference = when (type) {
is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped)
is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
else -> stringReference(type.toString())
}
private fun TxInfo.headerSubtitle(): TextReference {
val dateTime = DateTime(timestampInMillis)
val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime)
val time = DateTimeFormatters.timeFormatter.print(dateTime)
return stringReference("$date, $time")
}
private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) {
is TxInfo.TransactionStatus.Failed ->
resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending)))
is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending)
is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed)
}
private fun TxInfo.TransactionStatus.toUiStatus(): TransactionItemUM.Content.Status = when (this) {
TxInfo.TransactionStatus.Confirmed -> TransactionItemUM.Content.Status.Confirmed
TxInfo.TransactionStatus.Failed -> TransactionItemUM.Content.Status.Failed
TxInfo.TransactionStatus.Unconfirmed -> TransactionItemUM.Content.Status.Unconfirmed
}
// endregion

View file

@ -1,7 +1,13 @@
package com.tangem.features.txhistory.entity
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
/**
* UI model for the in-app transaction details ("Operation") card.
@ -14,16 +20,96 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
@Immutable
internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
/** Operation title, status-driven color is resolved at render time. */
val title: String
/** Shared top bar ("Nav bar"): type icon, status-driven title, date+time. */
val header: HeaderUM
/** Single-asset layout: Receive / Send / Transfer */
data class SingleAsset(
override val title: String,
override val header: HeaderUM,
val amountBlock: AmountBlockUM,
val counterparty: CounterpartyUM?,
val rows: List<InfoRowUM>,
) : TxHistoryDetailsUM
/** Two-asset layout: Swap / Onramp */
data class TwoAssets(
override val title: String,
override val header: HeaderUM,
) : TxHistoryDetailsUM
/**
* Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto
* [amount] and the secondary [fiatAmount].
*
* [isFailed] drives the failed visual state the amount is struck through, recolored to tertiary and carries no
* `+`/`` sign (mirrors the status-driven recolor in the shared header).
*/
@Immutable
data class AmountBlockUM(
val currencyIcon: CurrencyIconState,
val amount: TextReference,
val fiatAmount: TextReference,
val isFailed: Boolean,
)
/**
* A single info row of the details card: a [label] on the leading side and its [value] on the trailing side
* (e.g. `Network fee` `0.00056 ETH`, `Rate` `1 POL 0.36 USDT`). Rendered by [TxHistoryDetailsInfoRows].
*/
@Immutable
data class InfoRowUM(
val label: TextReference,
val value: TextReference,
)
/**
* Counterparty ("Recipient" / "From") card of the single-asset detail: a leading [avatar], the section [label] over
* the counterparty [title], and when [onCopyClick] is non-null a trailing copy button.
*
* 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.TxInfoToTxHistoryDetailsUMConverter]; 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`).
*
* @property label Section label above the counterparty: "Recipient" (outgoing) / "From" (incoming).
* @property title Counterparty value: brief address / account name / wallet name.
* @property avatar Leading avatar.
* @property onCopyClick Copy action; `null` hides the copy button (e.g. own-wallet has nothing to copy).
*/
@Immutable
data class CounterpartyUM(
val label: TextReference,
val title: TextReference,
val avatar: CounterpartyAvatar,
val onCopyClick: (() -> Unit)?,
)
/** Leading avatar of the [CounterpartyUM] card — the only thing that differs between counterparty kinds. */
@Immutable
sealed interface CounterpartyAvatar {
/** External blockchain address — rendered as an identicon generated from [rawAddress]. */
data class Address(val rawAddress: String) : CounterpartyAvatar
/** User's own account — rendered as [iconResId] tinted over [backgroundColor]. */
data class Account(
@DrawableRes val iconResId: Int,
val backgroundColor: Color,
) : CounterpartyAvatar
/** User's own wallet — rendered as the wallet card [deviceIconUM]. */
data class Wallet(val deviceIconUM: DeviceIconUM) : CounterpartyAvatar
}
/**
* Shared bottom-sheet top bar. The icon glyph and [title] text come from the transaction type; [status] drives
* the three visual states (in-progress / confirmed / failed) recoloring the icon circle and the title.
*/
@Immutable
data class HeaderUM(
@DrawableRes val iconRes: Int,
val status: TransactionItemUM.Content.Status,
val title: TextReference,
val subtitle: TextReference,
)
}

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.features.txhistory.component.TxHistoryDetailsComponent
import com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
@ -19,15 +20,24 @@ import javax.inject.Inject
@ModelScoped
internal class TxHistoryDetailsModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager,
paramsContainer: ParamsContainer,
) : Model() {
private val params: TxHistoryDetailsComponent.Params = paramsContainer.require()
private val converter = TxInfoToTxHistoryDetailsUMConverter()
private val converter = TxInfoToTxHistoryDetailsUMConverter(
currency = params.currency,
onCopyAddress = ::onCopyAddress,
)
val uiState: StateFlow<TxHistoryDetailsUM?> = params.txInfo
.map(converter::convert)
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null)
/** Copies a counterparty address to the clipboard — wired into the detail card's copy button via the converter. */
private fun onCopyAddress(address: String) {
clipboardManager.setText(text = address, isSensitive = false)
}
}

View file

@ -0,0 +1,101 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
/**
* Centered amount block of the single-asset card: token avatar (with network badge) over the big signed amount and the
* secondary fiat line.
*
* The failed state ([TxHistoryDetailsUM.AmountBlockUM.isFailed]) strikes the amount through and dims it (primary ->
* secondary) matching the status-driven recolor of the shared header. The `+`/`` sign is already dropped upstream
* by the converter for failed transactions (a failed tx moved nothing), so the [amount] text arrives unsigned here.
*/
@Composable
internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountBlockUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemCurrencyIcon(
state = amountBlock.currencyIcon,
modifier = Modifier.size(72.dp),
)
SpacerH(24.dp)
Text(
text = amountBlock.amount.resolveReference(),
color = if (amountBlock.isFailed) {
TangemTheme.colors3.text.secondary
} else {
TangemTheme.colors3.text.primary
},
style = TangemTheme.typography3.heading.medium,
textAlign = TextAlign.Center,
textDecoration = if (amountBlock.isFailed) TextDecoration.LineThrough else null,
)
SpacerH(4.dp)
Text(
text = amountBlock.fiatAmount.resolveReference(),
color = if (amountBlock.isFailed) {
TangemTheme.colors3.text.tertiary
} else {
TangemTheme.colors3.text.secondary
},
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.Center,
)
}
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsAmountBlockPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
) {
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false))
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = true))
}
}
}
private fun previewAmountBlock(isFailed: Boolean) = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
amount = stringReference("+ 350.31 USDT"),
fiatAmount = stringReference("$350.31"),
isFailed = isFailed,
)
// endregion

View file

@ -1,7 +1,7 @@
package com.tangem.features.txhistory.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@ -11,24 +11,53 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
@Composable
internal fun TxHistoryDetailsContent(state: TxHistoryDetailsUM, modifier: Modifier = Modifier) {
// Placeholder:card showing only the operation title, to verify tap -> sheet navigation
when (state) {
is TxHistoryDetailsUM.SingleAsset -> SingleAssetContent(state = state, modifier = modifier)
// TODO([REDACTED_TASK_KEY]): two-asset (Swap / Onramp) body — out of scope for the single-asset amount block ticket.
is TxHistoryDetailsUM.TwoAssets -> TwoAssetsPlaceholder(state = state, modifier = modifier)
}
}
@Composable
private fun SingleAssetContent(state: TxHistoryDetailsUM.SingleAsset, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
TxHistoryDetailsAmountBlock(amountBlock = state.amountBlock)
state.counterparty?.let { counterparty ->
TxHistoryDetailsCounterpartyRow(
counterparty = counterparty,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
TxHistoryDetailsInfoRows(
rows = state.rows,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
}
@Composable
private fun TwoAssetsPlaceholder(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors2.surface.level2)
.heightIn(min = 240.dp)
.padding(TangemTheme.dimens2.x6),
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = state.title,
color = TangemTheme.colors2.text.neutral.primary,
style = TangemTheme.typography2.headingSemibold28,
text = state.header.title.resolveReference(),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
textAlign = TextAlign.Center,
)
}

View file

@ -0,0 +1,160 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowText
import com.tangem.core.ui.ds2.row.TangemRowTextRole
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_copy_20
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.CounterpartyAvatar
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.CounterpartyUM
import com.tangem.features.txhistory.impl.R
/**
* Counterparty ("Recipient" / "From") card of the single-asset detail, built on the DS3 [TangemRow] inside a tinted
* `bg.opaque.primary` cell. The layout is identical across counterparty kinds only the leading
* [avatar][CounterpartyUM.avatar] varies (see [CounterpartyAvatar]) and the trailing copy button is shown only when
* [CounterpartyUM.onCopyClick] is non-null.
*
* The section [label][CounterpartyUM.label] sits above the counterparty value. [TangemRow] renders its `titleSlot`
* above the `subtitleSlot`, so the slots are filled inverted to their semantic role: the small caption label goes in
* the (upper) title slot and the body-sized value goes in the (lower) subtitle slot.
*
* @param counterparty Counterparty data driving the avatar, labels and the copy action.
* @param modifier Modifier applied to the cell container.
*/
@Composable
internal fun TxHistoryDetailsCounterpartyRow(counterparty: CounterpartyUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.background(TangemTheme.colors3.bg.opaque.primary),
) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
contentLead = TangemRowContentLead.Start,
startSlot = { CounterpartyAvatar(counterparty.avatar) },
titleSlot = { TangemRowText(text = counterparty.label, role = TangemRowTextRole.Subtitle) },
subtitleSlot = { TangemRowText(text = counterparty.title, role = TangemRowTextRole.Title) },
endSlot = counterparty.onCopyClick?.let { onCopyClick ->
{ CounterpartyCopyButton(onClick = onCopyClick) }
},
)
}
}
@Composable
private fun CounterpartyAvatar(avatar: CounterpartyAvatar, modifier: Modifier = Modifier) {
val avatarModifier = modifier.size(40.dp)
when (avatar) {
is CounterpartyAvatar.Address -> IdentIcon(
address = avatar.rawAddress,
modifier = avatarModifier.clip(CircleShape),
)
is CounterpartyAvatar.Account -> Box(
modifier = avatarModifier
.clip(CircleShape)
.background(avatar.backgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = avatar.iconResId),
contentDescription = null,
tint = TangemTheme.colors3.icon.staticDark,
modifier = Modifier.size(20.dp),
)
}
is CounterpartyAvatar.Wallet -> TangemDeviceIcon(
state = avatar.deviceIconUM,
modifier = avatarModifier,
)
}
}
@Composable
private fun CounterpartyCopyButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
TangemButton(
modifier = modifier,
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X9,
iconStart = TangemIconUM.Icon(Icons.ic_copy_20),
contentDescription = resourceReference(R.string.common_copy).resolveReference(),
onClick = onClick,
)
}
// region Preview
@Suppress("MagicNumber")
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsCounterpartyRowPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier.background(TangemTheme.colors3.bg.primary).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TxHistoryDetailsCounterpartyRow(
counterparty = CounterpartyUM(
label = stringReference("Recipient"),
title = stringReference("33Bd321fS...ga21412B"),
avatar = CounterpartyAvatar.Address(rawAddress = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359"),
onCopyClick = {},
),
)
TxHistoryDetailsCounterpartyRow(
counterparty = CounterpartyUM(
label = stringReference("Recipient"),
title = stringReference("Danil Kolbasenko"),
avatar = CounterpartyAvatar.Account(
iconResId = R.drawable.ic_arrow_down_24,
backgroundColor = Color(0xFF704AF1),
),
onCopyClick = {},
),
)
TxHistoryDetailsCounterpartyRow(
counterparty = CounterpartyUM(
label = stringReference("Recipient"),
title = stringReference("Tangem wallet"),
avatar = CounterpartyAvatar.Wallet(
deviceIconUM = DeviceIconUM.Card(mainColor = Color(0xFF1E1E1E), secondColor = null),
),
onCopyClick = null,
),
)
}
}
}
// endregion

View file

@ -0,0 +1,94 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowText
import com.tangem.core.ui.ds2.row.TangemRowTextRole
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM
/**
* Info-rows block of the transaction details card: a vertical list of DS3 [TangemRow]s (label on the leading side,
* value on the trailing side e.g. `Network fee`, `Rate`).
*
* Divider handling matches the design: a single row renders without a divider, while a multi-row block draws an inset
* bottom divider under every row except the last. The same block therefore serves both the single-asset card (one
* `Network fee` row) and the two-asset / exchange card (`Network fee` + `Rate` + ).
*
* The value is rendered in `text/secondary` to match the design [TangemRowText]'s `Value` role is primary-colored, so
* the trailing slot uses a plain [Text] tuned to body/medium + secondary instead.
*
* @param rows Rows to render in order. An empty list renders nothing callers should skip the block when empty.
* @param modifier Modifier applied to the list container.
*/
@Composable
internal fun TxHistoryDetailsInfoRows(rows: List<InfoRowUM>, modifier: Modifier = Modifier) {
if (rows.isEmpty()) return
Column(
modifier = modifier,
) {
val lastIndex = rows.lastIndex
rows.forEachIndexed { index, row ->
TangemRow(
divider = index < lastIndex,
contentLead = TangemRowContentLead.Start,
titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) },
valueSlot = {
Text(
text = row.value.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsInfoRowsPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier.background(TangemTheme.colors3.bg.primary).padding(16.dp),
) {
// Multiple rows — dividers between rows, none after the last
TxHistoryDetailsInfoRows(
rows = listOf(
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")),
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)
// Single row — no divider
TxHistoryDetailsInfoRows(
modifier = Modifier.padding(top = 16.dp),
rows = listOf(
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)
}
}
}
// endregion

View file

@ -0,0 +1,87 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
/**
* The transaction details bottom sheet ("Operation"): the [TangemModalBottomSheet] shell shared by all transaction
* types, with the [TxHistoryDetailsTopNavigation] header in the title slot and [TxHistoryDetailsContent] (single-asset
* or two-asset body) as the content.
*
* Extracted from `DefaultTxHistoryDetailsComponent` so the whole sheet header + body is previewable in isolation.
*
* @param state Sheet state. `null` keeps the sheet hidden (the modal renders its empty placeholder).
* @param onDismiss Invoked on close / dismiss request.
*/
@Composable
internal fun TxHistoryDetailsModalBottomSheetContent(state: TxHistoryDetailsUM?, onDismiss: () -> Unit) {
TangemModalBottomSheet<TxHistoryDetailsUM>(
containerColor = TangemTheme.colors3.bg.secondary,
config = TangemBottomSheetConfig(
isShown = state != null,
onDismissRequest = onDismiss,
content = state ?: TangemBottomSheetConfigContent.Empty,
),
title = {
state?.let { um -> TxHistoryDetailsTopNavigation(header = um.header, onCloseClick = onDismiss) }
},
content = { um -> TxHistoryDetailsContent(state = um) },
)
}
// region Preview
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO)
@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = UI_MODE_NIGHT_YES)
@Composable
private fun TxHistoryDetailsModalBottomSheetContentPreview() {
TangemThemePreviewRedesign {
TxHistoryDetailsModalBottomSheetContent(state = previewSingleAsset(), onDismiss = {})
}
}
/** Fully-populated single-asset state exercising every sub-view: header, amount block, counterparty and info rows. */
private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
header = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_arrow_up_24,
status = Status.Confirmed,
title = stringReference("Sent"),
subtitle = stringReference("Jan 20 2026, 9:24 PM"),
),
amountBlock = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
amount = stringReference("- 350.31 USDT"),
fiatAmount = stringReference("$350.31"),
isFailed = false,
),
counterparty = TxHistoryDetailsUM.CounterpartyUM(
label = stringReference("Recipient"),
title = stringReference("33Bd321fS...ga21412B"),
avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(
rawAddress = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359",
),
onCopyClick = {},
),
rows = listOf(
TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)
// endregion

View file

@ -0,0 +1,171 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.Close
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_dots_horizontal_20
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
/**
* Shared top navigation ("Nav bar") for the transaction details bottom sheet, common to all transaction types.
*
* Built on the redesigned [TangemTopNavigation]: a leading status-tinted action icon ([StatusActionIcon]) in the start
* slot, a status-colored [title][TxHistoryDetailsUM.HeaderUM.title] over a date subtitle in the center slot, and the
* trailing context-menu (``, grouped in a Material pill) + close (``) buttons in the end slots.
*
* Three visual states are driven by [TxHistoryDetailsUM.HeaderUM.status]: the action-icon circle background, the icon
* tint and the title color change between in-progress (brand/blue), confirmed (neutral) and failed (red). The icon
* glyph itself is kept as-is on failure only recolored.
*
* Hosted inside a modal bottom sheet, so [WindowInsets] is zeroed (no status-bar reservation) and the background blur
* is disabled.
*/
@Composable
internal fun TxHistoryDetailsTopNavigation(
header: TxHistoryDetailsUM.HeaderUM,
onCloseClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemTopNavigation(
modifier = modifier.padding(top = 8.dp),
windowInsets = WindowInsets(0),
blurBackground = false,
startButton = { StatusActionIcon(iconRes = header.iconRes, status = header.status) },
endButtonsGroup = {
// Context menu. Click handling is intentionally not wired yet.
TangemButton(
variant = TangemButton.Variant.Ghost,
iconStart = TangemIconUM.Icon(Icons.ic_dots_horizontal_20),
contentDescription = resourceReference(R.string.common_more).resolveReference(),
onClick = {},
)
},
endButton = { TangemButton.Close(onClick = onCloseClick) },
contentColumn = {
Text(
text = header.title.resolveReference(),
color = header.status.titleColor,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = header.subtitle.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
},
)
}
@Composable
private fun StatusActionIcon(iconRes: Int, status: Status, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(44.dp)
.clip(CircleShape)
.background(status.circleBackground),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
tint = status.iconTint,
modifier = Modifier.size(20.dp),
)
}
}
// region Status -> colors3 tokens (three states)
private val Status.circleBackground: Color
@Composable get() = when (this) {
is Status.Confirmed -> TangemTheme.colors3.bg.tertiary
is Status.Unconfirmed -> TangemTheme.colors3.bg.status.infoSubtle
is Status.Failed -> TangemTheme.colors3.bg.status.errorSubtle
}
private val Status.iconTint: Color
@Composable get() = when (this) {
is Status.Confirmed -> TangemTheme.colors3.icon.primary
is Status.Unconfirmed -> TangemTheme.colors3.icon.accent.blue
is Status.Failed -> TangemTheme.colors3.icon.accent.red
}
private val Status.titleColor: Color
@Composable get() = when (this) {
is Status.Confirmed -> TangemTheme.colors3.text.primary
is Status.Unconfirmed -> TangemTheme.colors3.text.brand
is Status.Failed -> TangemTheme.colors3.text.status.error
}
// endregion
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsTopNavigationPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.fillMaxWidth()
.background(TangemTheme.colors3.bg.primary),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TxHistoryDetailsTopNavigation(
header = previewHeader(Status.Unconfirmed, stringReference("Swapping")),
onCloseClick = {},
)
TxHistoryDetailsTopNavigation(
header = previewHeader(Status.Confirmed, stringReference("Swapped")),
onCloseClick = {},
)
TxHistoryDetailsTopNavigation(
header = previewHeader(Status.Failed, stringReference("Swapping failed")),
onCloseClick = {},
)
}
}
}
private fun previewHeader(status: Status, title: TextReference) = TxHistoryDetailsUM.HeaderUM(
iconRes = R.drawable.ic_exchange_vertical_24,
status = status,
title = title,
subtitle = stringReference("Jan 20 2026, 9:24 PM"),
)
// endregion

View file

@ -1,9 +1,20 @@
package com.tangem.features.txhistory.converter
import android.text.format.DateFormat
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.network.TxInfo.TransactionType
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
import com.tangem.features.txhistory.impl.R
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@ -11,7 +22,25 @@ import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class TxInfoToTxHistoryDetailsUMConverterTest {
private val converter = TxInfoToTxHistoryDetailsUMConverter()
private val currency = MockCryptoCurrencyFactory().ethereum
private val copiedAddresses = mutableListOf<String>()
private val converter = TxInfoToTxHistoryDetailsUMConverter(
currency = currency,
onCopyAddress = copiedAddresses::add,
)
@BeforeEach
fun setUp() {
// The header subtitle formats the date via DateTimeFormatters -> DateFormat.getBestDateTimePattern,
// which is an Android stub on the JVM. Mirror the DateTimeFormattersTest mock so convert() runs.
mockkStatic(DateFormat::class)
every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() }
}
@AfterEach
fun tearDown() {
unmockkStatic(DateFormat::class)
}
@Test
fun `GIVEN Swap WHEN convert THEN TwoAssets`() {
@ -48,30 +77,170 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
}
@Test
fun `GIVEN tx WHEN convert THEN title is the transaction type`() {
fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() {
// Arrange
val type = TransactionType.Transfer
val tx = txInfo(type = type)
val tx = txInfo(type = TransactionType.Transfer)
// Act
val result = converter.convert(tx)
val header = converter.convert(tx).header
// Assert
assertThat(result.title).isEqualTo(type.toString())
assertThat(header.iconRes).isEqualTo(R.drawable.ic_arrow_down_24)
assertThat(header.status).isEqualTo(TransactionItemUM.Content.Status.Confirmed)
assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred))
}
private fun txInfo(type: TransactionType): TxInfo = TxInfo(
@Test
fun `GIVEN Swap WHEN convert THEN header has exchange icon`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap)
// Act
val header = converter.convert(tx).header
// Assert
assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24)
}
@Test
fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = false)
// Act
val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock
// Assert
assertThat(amountBlock.amount.resolveString()).startsWith("+ ")
assertThat(amountBlock.isFailed).isFalse()
}
@Test
fun `GIVEN outgoing Transfer WHEN convert THEN amount block has minus sign`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true)
// Act
val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock
// Assert
assertThat(amountBlock.amount.resolveString()).startsWith("- ")
}
@Test
fun `GIVEN zero amount WHEN convert THEN amount block has no sign`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO)
// Act
val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock
// Assert
val amount = amountBlock.amount.resolveString()
assertThat(amount).doesNotContain("+")
assertThat(amount).doesNotContain("-")
}
@Test
fun `GIVEN failed outgoing Transfer WHEN convert THEN amount block is failed and drops the sign`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
status = TxInfo.TransactionStatus.Failed,
)
// Act
val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock
// Assert
assertThat(amountBlock.isFailed).isTrue()
val amount = amountBlock.amount.resolveString()
assertThat(amount).doesNotContain("+")
assertThat(amount).doesNotContain("-")
}
@Test
fun `GIVEN no interaction address WHEN convert THEN counterparty is null`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer, interactionAddressType = null)
// Act
val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty
// Assert
assertThat(counterparty).isNull()
}
@Test
fun `GIVEN incoming Transfer with User address WHEN convert THEN address-avatar counterparty with From label`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = false,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty
// Assert
assertThat(counterparty?.avatar).isEqualTo(TxHistoryDetailsUM.CounterpartyAvatar.Address(USER_ADDRESS))
assertThat(counterparty?.label).isEqualTo(resourceReference(R.string.common_from))
}
@Test
fun `GIVEN outgoing Transfer with User address WHEN convert THEN counterparty has Recipient label`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
isOutgoing = true,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
// Act
val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty
// Assert
assertThat(counterparty?.label).isEqualTo(resourceReference(R.string.send_recipient))
}
@Test
fun `GIVEN address counterparty WHEN onCopyClick invoked THEN raw address is copied`() {
// Arrange
val tx = txInfo(
type = TransactionType.Transfer,
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
)
val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty
// Act
counterparty?.onCopyClick?.invoke()
// Assert
assertThat(copiedAddresses).containsExactly(USER_ADDRESS)
}
private fun txInfo(
type: TransactionType,
isOutgoing: Boolean = false,
status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed,
amount: BigDecimal = BigDecimal.ONE,
interactionAddressType: TxInfo.InteractionAddressType? = null,
): TxInfo = TxInfo(
txHash = TX_HASH,
timestampInMillis = TIMESTAMP,
isOutgoing = false,
isOutgoing = isOutgoing,
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
interactionAddressType = null,
status = TxInfo.TransactionStatus.Confirmed,
interactionAddressType = interactionAddressType,
status = status,
type = type,
amount = BigDecimal.ONE,
amount = amount,
)
private fun TextReference.resolveString(): String = (this as TextReference.Str).value
private companion object {
const val TX_HASH = "0xtxhash"
const val TIMESTAMP = 1_700_000_000_000L