Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-19 12:37:29 +03:00
parent a6e7008772
commit 5fce0646d8
6 changed files with 99 additions and 24 deletions

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -25,6 +26,8 @@ import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -201,9 +204,15 @@ private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Mo
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = modifier, modifier = modifier,
) )
is ContentSubtitle.PlainAddress -> PlainAddressText(
subtitle = subtitle,
status = status,
modifier = modifier,
)
is ContentSubtitle.ExternalAddress -> InlineImageSubtitle( is ContentSubtitle.ExternalAddress -> InlineImageSubtitle(
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.briefAddress), template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.briefAddress),
color = tertiary, color = tertiary,
afterIconColor = if (isFailed) tertiary else primary,
modifier = modifier, modifier = modifier,
) { ) {
IdentIcon( IdentIcon(
@ -256,6 +265,33 @@ private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Mo
} }
} }
@Composable
private fun PlainAddressText(subtitle: ContentSubtitle.PlainAddress, status: Status, modifier: Modifier = Modifier) {
val full = subtitle.text.resolveReference()
val highlightColor = if (status is Status.Failed) {
TangemTheme.colors2.text.neutral.tertiary
} else {
TangemTheme.colors2.text.neutral.primary
}
val text = remember(full, subtitle.highlight, highlightColor) {
buildAnnotatedString {
append(full)
val start = full.lastIndexOf(subtitle.highlight)
if (start >= 0) {
addStyle(SpanStyle(color = highlightColor), start, start + subtitle.highlight.length)
}
}
}
Text(
text = text,
color = TangemTheme.colors2.text.neutral.tertiary,
style = TangemTheme.typography2.captionMedium12,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
private fun ContentSubtitle.Direction.templateResId(): Int = when (this) { private fun ContentSubtitle.Direction.templateResId(): Int = when (this) {
ContentSubtitle.Direction.TO -> R.string.transaction_history_to_inline_address ContentSubtitle.Direction.TO -> R.string.transaction_history_to_inline_address
ContentSubtitle.Direction.FROM -> R.string.transaction_history_from_inline_address ContentSubtitle.Direction.FROM -> R.string.transaction_history_from_inline_address

View file

@ -55,9 +55,16 @@ sealed interface TransactionItemUM {
/** Subtitle variants for [Content] rows. */ /** Subtitle variants for [Content] rows. */
@Immutable @Immutable
sealed interface ContentSubtitle { sealed interface ContentSubtitle {
/** Plain text — for types without a directly-displayable address (Operation, GaslessFee, ClaimRewards, etc.). */ /** Plain text — for types without a displayable address (Operation, GaslessFee, ClaimRewards, etc.). */
data class Plain(val text: TextReference) : ContentSubtitle data class Plain(val text: TextReference) : ContentSubtitle
/**
* Plain-text address subtitle without an identicon renders "prefix: <address>", where [highlight]
* (a substring of the resolved [text]) is painted in the primary text color while the prefix stays
* tertiary. Used for contract / validator addresses, external swap addresses and yield "for: <address>".
*/
data class PlainAddress(val text: TextReference, val highlight: String) : ContentSubtitle
/** /**
* External counterparty address renders as "to/from: <identicon> <briefAddress>". * External counterparty address renders as "to/from: <identicon> <briefAddress>".
* Used for Transfer to/from external addresses. * Used for Transfer to/from external addresses.

View file

@ -2,6 +2,7 @@ package com.tangem.feature.swap.model
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.PermissionDataState import com.tangem.feature.swap.domain.models.ui.PermissionDataState
@ -41,7 +42,7 @@ internal class SwapModelGetSelectApprovalTypeParamsTest : SwapModelTestBase() {
@Test @Test
fun `GIVEN provider state is not QuotesLoadedState THEN returns null`() { fun `GIVEN provider state is not QuotesLoadedState THEN returns null`() {
val provider = swapProvider() val provider = swapProvider()
val notLoaded: SwapState.EmptyAmountState = mockk(relaxed = true) val notLoaded = SwapState.EmptyAmountState(zeroAmountEquivalent = TextReference.EMPTY)
val model = createModel() val model = createModel()
model.dataState = model.dataState.copy( model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = swapCurrencyStatus(), fromSwapCurrencyStatus = swapCurrencyStatus(),

View file

@ -9,6 +9,12 @@ plugins {
android { android {
namespace = "com.tangem.features.txhistory.impl" namespace = "com.tangem.features.txhistory.impl"
packaging {
resources {
merges += "paymentrequest.proto"
}
}
} }
dependencies { dependencies {
/* Project - API */ /* Project - API */

View file

@ -83,7 +83,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
uiStatus = uiStatus, uiStatus = uiStatus,
title = stringReference(type.name), title = stringReference(type.name),
iconRes = tx.directionalIcon(), iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()), subtitle = tx.extractAddressSubtitle(),
) )
private fun swapContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content = private fun swapContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
@ -92,7 +92,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
uiStatus = uiStatus, uiStatus = uiStatus,
title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped), title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
iconRes = tx.directionalIcon(), iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()), subtitle = tx.extractAddressSubtitle(),
) )
private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content { private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content {
@ -112,7 +112,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
rawAddress = counterpartyAddress, rawAddress = counterpartyAddress,
briefAddress = counterpartyAddress.toBriefAddressFormat(), briefAddress = counterpartyAddress.toBriefAddressFormat(),
) )
else -> ContentSubtitle.Plain(tx.extractSubtitleByAddressType()) else -> tx.extractAddressSubtitle()
} }
return buildContent( return buildContent(
@ -145,7 +145,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
uiStatus = uiStatus, uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_topup), title = resourceReference(R.string.yield_module_transaction_topup),
iconRes = tx.directionalIcon(), iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)), subtitle = tx.yieldSupplySubtitle(currency, type),
) )
private fun yieldDeployContractContent( private fun yieldDeployContractContent(
@ -157,7 +157,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
uiStatus = uiStatus, uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_deploy_contract), title = resourceReference(R.string.yield_module_transaction_deploy_contract),
iconRes = R.drawable.ic_doc_24, iconRes = R.drawable.ic_doc_24,
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)), subtitle = tx.yieldSupplySubtitle(currency, type),
) )
private fun yieldInitializeTokenContent( private fun yieldInitializeTokenContent(
@ -169,7 +169,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
uiStatus = uiStatus, uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_initialize), title = resourceReference(R.string.yield_module_transaction_initialize),
iconRes = R.drawable.ic_gear_24, iconRes = R.drawable.ic_gear_24,
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)), subtitle = tx.yieldSupplySubtitle(currency, type),
) )
private fun yieldReactivateTokenContent( private fun yieldReactivateTokenContent(
@ -181,7 +181,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
uiStatus = uiStatus, uiStatus = uiStatus,
title = resourceReference(R.string.yield_module_transaction_reactivate), title = resourceReference(R.string.yield_module_transaction_reactivate),
iconRes = R.drawable.ic_refresh_24, iconRes = R.drawable.ic_refresh_24,
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)), subtitle = tx.yieldSupplySubtitle(currency, type),
) )
private fun yieldSendContent( private fun yieldSendContent(
@ -197,7 +197,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
resourceReference(R.string.common_transfer) resourceReference(R.string.common_transfer)
}, },
iconRes = tx.directionalIcon(), iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)), subtitle = tx.yieldSupplySubtitle(currency, type),
hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing, hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing,
) )
@ -209,7 +209,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
uiStatus = uiStatus, uiStatus = uiStatus,
title = resourceReference(R.string.transaction_history_operation), title = resourceReference(R.string.transaction_history_operation),
iconRes = tx.directionalIcon(), iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()), subtitle = tx.extractAddressSubtitle(),
) )
private fun gaslessFeeContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content = private fun gaslessFeeContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
@ -218,7 +218,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
uiStatus = uiStatus, uiStatus = uiStatus,
title = resourceReference(R.string.gasless_transaction_fee), title = resourceReference(R.string.gasless_transaction_fee),
iconRes = tx.directionalIcon(), iconRes = tx.directionalIcon(),
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()), subtitle = tx.extractAddressSubtitle(),
) )
private fun buildContent( private fun buildContent(
@ -283,15 +283,21 @@ private fun resolveOwnSubtitle(
} }
} }
private fun TxInfo.yieldSupplySubtitle(currency: CryptoCurrency, type: TransactionType.YieldSupply): TextReference { private fun TxInfo.yieldSupplySubtitle(currency: CryptoCurrency, type: TransactionType.YieldSupply): ContentSubtitle {
if (currency is CryptoCurrency.Coin) { if (currency is CryptoCurrency.Coin) {
return if (type is TransactionType.YieldSupply.Send) { return if (type is TransactionType.YieldSupply.Send) {
extractSubtitleByAddressType() extractAddressSubtitle()
} else { } else {
resourceReference( val briefAddress = type.address?.toBriefAddressFormat()
val text = resourceReference(
R.string.transaction_history_transaction_for_address, R.string.transaction_history_transaction_for_address,
wrappedList(type.address?.toBriefAddressFormat().orEmpty()), wrappedList(briefAddress.orEmpty()),
) )
if (briefAddress.isNullOrEmpty()) {
ContentSubtitle.Plain(text)
} else {
ContentSubtitle.PlainAddress(text = text, highlight = briefAddress)
}
} }
} }
return when (type) { return when (type) {
@ -304,15 +310,34 @@ private fun TxInfo.yieldSupplySubtitle(currency: CryptoCurrency, type: Transacti
is TransactionType.YieldSupply.Send -> if (!isOutgoing && type.isYieldSupplyWithdraw) { is TransactionType.YieldSupply.Send -> if (!isOutgoing && type.isYieldSupplyWithdraw) {
amountSubtitle(currency, R.string.yield_module_transaction_exit_subtitle) amountSubtitle(currency, R.string.yield_module_transaction_exit_subtitle)
} else { } else {
extractSubtitleByAddressType() extractAddressSubtitle()
} }
else -> extractSubtitleByAddressType() else -> extractAddressSubtitle()
} }
} }
private fun TxInfo.amountSubtitle(currency: CryptoCurrency, @StringRes resId: Int): TextReference { private fun TxInfo.amountSubtitle(currency: CryptoCurrency, @StringRes resId: Int): ContentSubtitle.Plain {
val formatted = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) } val formatted = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
return resourceReference(resId, wrappedList(formatted)) return ContentSubtitle.Plain(resourceReference(resId, wrappedList(formatted)))
}
private fun TxInfo.extractAddressSubtitle(): ContentSubtitle {
val text = extractSubtitleByAddressType()
val highlight = interactionAddressType.highlightableBriefAddress()
return if (highlight == null) {
ContentSubtitle.Plain(text)
} else {
ContentSubtitle.PlainAddress(text = text, highlight = highlight)
}
}
private fun TxInfo.InteractionAddressType?.highlightableBriefAddress(): String? = when (this) {
is TxInfo.InteractionAddressType.Contract -> address.toBriefAddressFormat()
is TxInfo.InteractionAddressType.User -> address.toBriefAddressFormat()
is TxInfo.InteractionAddressType.Validator -> address.toBriefAddressFormat()
is TxInfo.InteractionAddressType.Multiple,
null,
-> null
} }
private fun TxInfo.extractSubtitleByAddressType(): TextReference = private fun TxInfo.extractSubtitleByAddressType(): TextReference =

View file

@ -341,7 +341,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
} }
@Test @Test
fun `GIVEN Transfer with non-User interaction WHEN convert THEN Plain subtitle`() { fun `GIVEN Transfer with non-User interaction WHEN convert THEN PlainAddress subtitle`() {
val tx = txInfo( val tx = txInfo(
type = TransactionType.Transfer, type = TransactionType.Transfer,
isOutgoing = true, isOutgoing = true,
@ -350,7 +350,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content val result = coinConverter.convert(tx) as TransactionItemUM.Content
assertThat(result.subtitle).isInstanceOf(ContentSubtitle.Plain::class.java) assertThat(result.subtitle).isInstanceOf(ContentSubtitle.PlainAddress::class.java)
assertThat(result.title).isEqualTo(resRef(R.string.common_sent)) assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
} }
@ -575,7 +575,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content val result = coinConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain val subtitle = result.subtitle as ContentSubtitle.PlainAddress
val res = subtitle.text as TextReference.Res val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.transaction_history_contract_address) assertThat(res.id).isEqualTo(R.string.transaction_history_contract_address)
} }
@ -623,7 +623,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
val result = coinConverter.convert(tx) as TransactionItemUM.Content val result = coinConverter.convert(tx) as TransactionItemUM.Content
val subtitle = result.subtitle as ContentSubtitle.Plain val subtitle = result.subtitle as ContentSubtitle.PlainAddress
val res = subtitle.text as TextReference.Res val res = subtitle.text as TextReference.Res
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_validator) assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_validator)
} }