Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 13:34:43 +03:00
parent fc319f2b70
commit 30788b2c52
6 changed files with 279 additions and 39 deletions

View file

@ -2688,6 +2688,8 @@
<string name="yield_module_transaction_exit_subtitle">%1$s withdrawn from Aave</string>
<string name="yield_module_transaction_initialize">Yield Mode initialized</string>
<string name="yield_module_transaction_reactivate">Yield Mode reactivated</string>
<string name="yield_module_transaction_returned">Returned</string>
<string name="yield_module_transaction_supplied">Supplied</string>
<string name="yield_module_transaction_topup">Supply to Aave</string>
<string name="yield_module_transaction_topup_subtitle">%1$s supplied to Aave</string>
<string name="yield_module_transaction_withdraw">Withdraw from Aave</string>

View file

@ -27,6 +27,9 @@ import com.tangem.utils.toBriefAddressFormat
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/** Website the yield-supply protocol row links to — the hard-wired Aave integration's home page. */
private const val AAVE_WEBSITE = "https://aave.com/"
/**
* Converts an on-chain [TxInfo] to the [TxHistoryDetailsUM.SingleAsset] details card (Receive / Send / Transfer /
* staking / yield-supply). A two-asset swap always surfaces as an [com.tangem.domain.txhistory.model.ExpressTx.Swap]
@ -62,14 +65,17 @@ internal class OnChainTxToDetailsUMConverter(
/**
* Protocol row of a yield-supply tx: the DeFi protocol the funds are supplied to. The yield-supply product is a
* single hard-wired integration across the app (Aave the [yield_module_provider][R.string.yield_module_provider]
* name), so the value is that constant provider rather than a per-tx resolved name. Mutually exclusive with
* [validatorRow] a tx is either staking or yield-supply, never both.
* name), so the value is that constant provider rather than a per-tx resolved name, and the tap opens the
* constant [AAVE_WEBSITE]. Mutually exclusive with [validatorRow] a tx is either staking or yield-supply,
* never both.
*/
private fun TxInfo.protocolRow(): TxHistoryDetailsUM.InfoRowUM? {
if (type !is TransactionType.YieldSupply) return null
return TxHistoryDetailsUM.InfoRowUM(
label = resourceReference(R.string.staking_validator),
value = resourceReference(R.string.yield_module_provider),
trailingIconRes = R.drawable.ic_arrow_top_right_24,
onClick = { onOpenValidator(AAVE_WEBSITE) },
)
}
@ -125,11 +131,37 @@ internal class OnChainTxToDetailsUMConverter(
}
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
icon = amountIcon(),
amount = stringReference(signedAmount(currency)),
label = amountLabel(),
isFailed = status is TxInfo.TransactionStatus.Failed,
)
/**
* Amount icon. Yield-supply enter/exit shows the asset paired with the hard-wired Aave protocol icon, ordered by
* direction (Aave leads on "Supplied"/enter, the asset leads on "Returned"/exit mirroring the two states in the
* design); every other type shows the single token avatar. Only enter/exit are paired for now the remaining
* yield-supply variants (topup / withdraw) can adopt the same pair later.
*/
private fun TxInfo.amountIcon(): TxHistoryDetailsUM.AmountIconUM {
val asset = TxHistoryDetailsUM.AmountIconUM.Item.Currency(iconStateConverter.convert(currency))
val aave = TxHistoryDetailsUM.AmountIconUM.Item.Resource(R.drawable.img_aave_22)
return when (type) {
is TransactionType.YieldSupply.Enter ->
TxHistoryDetailsUM.AmountIconUM.OverlappingPair(leading = aave, trailing = asset)
is TransactionType.YieldSupply.Exit ->
TxHistoryDetailsUM.AmountIconUM.OverlappingPair(leading = asset, trailing = aave)
else -> TxHistoryDetailsUM.AmountIconUM.Single(iconStateConverter.convert(currency))
}
}
/** "Supplied"/"Returned" label above the amount for yield-supply enter/exit; `null` (no label) for other types. */
private fun TxInfo.amountLabel(): TextReference? = when (type) {
is TransactionType.YieldSupply.Enter -> resourceReference(R.string.yield_module_transaction_supplied)
is TransactionType.YieldSupply.Exit -> resourceReference(R.string.yield_module_transaction_returned)
else -> null
}
/**
* 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
@ -140,6 +172,12 @@ internal class OnChainTxToDetailsUMConverter(
* now a follow-up.
*/
private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? {
// Contract interactions (yield-supply / staking / approve) talk to a protocol/validator, not a real recipient —
// no copyable counterparty card.
val isContractInteraction = type is TransactionType.YieldSupply ||
type is TransactionType.Staking ||
type is TransactionType.Approve
if (isContractInteraction) return null
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
return TxHistoryDetailsUM.CounterpartyUM(
label = counterpartyLabel(),
@ -158,13 +196,15 @@ internal class OnChainTxToDetailsUMConverter(
/**
* 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].
* otherwise, and is dropped for zero amounts, for the failed state (a failed tx moved nothing) and for yield-supply
* enter/exit (which reads "Supplied"/"Returned" via the label instead of a signed transfer) 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 -> ""
type is TransactionType.YieldSupply.Enter || type is TransactionType.YieldSupply.Exit -> ""
amount.isZero() -> ""
isOutgoing -> "${StringsSigns.MINUS} "
else -> "${StringsSigns.PLUS} "

View file

@ -137,22 +137,51 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
}
/**
* Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto
* [amount] and the secondary [fiatAmount].
* Centered amount block of the single-asset card: the [icon] (a single token avatar, or a yield-supply asset+Aave
* pair), an optional [label] above the amount ("Supplied" / "Returned" for yield supply), the big [amount] and the
* secondary [fiatAmount].
*
* [fiatAmount] is `null` while no fiat value is available (`TxInfo` has no fiat field yet) the fiat line is then
* omitted entirely rather than shown as a placeholder.
* [label] is `null` for the plain single-asset types (Send / Receive / Transfer / staking) the line is then
* omitted. [fiatAmount] is `null` while no fiat value is available (`TxInfo` has no fiat field yet) the fiat line
* is then omitted entirely rather than shown as a placeholder.
*
* [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).
*/
data class AmountBlockUM(
val currencyIcon: CurrencyIconState,
val icon: AmountIconUM,
val amount: TextReference,
val label: TextReference? = null,
val fiatAmount: TextReference? = null,
val isFailed: Boolean,
)
/** Icon shown above the amount of the single-asset card. */
@Immutable
sealed interface AmountIconUM {
/** A single token avatar — Send / Receive / Transfer / staking. */
data class Single(val currencyIcon: CurrencyIconState) : AmountIconUM
/**
* Two overlapping avatars: [leading] is drawn on top (left, with a background-colored ring), [trailing] behind
* it (right). Used by yield supply the asset and the hard-wired Aave protocol icon with the order set by the
* transaction direction: Aave leads on "Supplied" (enter), the asset leads on "Returned" (exit).
*/
data class OverlappingPair(val leading: Item, val trailing: Item) : AmountIconUM
/** One avatar of an [OverlappingPair]. */
@Immutable
sealed interface Item {
/** A token avatar backed by its [state]. */
data class Currency(val state: CurrencyIconState) : Item
/** A hard-wired drawable (e.g. the Aave protocol icon). */
data class Resource(@DrawableRes val resId: Int) : Item
}
}
/**
* 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].

View file

@ -1,15 +1,22 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.Image
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
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.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
@ -25,13 +32,18 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
private val IconSize = 72.dp
private val PairOverlap = 20.dp
private val PairRing = 4.dp
private val LeadingSize = IconSize + PairRing * 2
/**
* Centered amount block of the single-asset card: token avatar (with network badge) over the big signed amount and the
* secondary fiat line.
* Centered amount block of the single-asset card: the token avatar (single, or a yield-supply asset+Aave pair), an
* optional label ("Supplied"/"Returned"), the big 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.
* secondary) matching the status-driven recolor of the shared header. The `+`/`` sign is resolved upstream by the
* converter (dropped for failed and yield-supply transactions), so the [amount] text arrives ready to render here.
*/
@Composable
internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountBlockUM, modifier: Modifier = Modifier) {
@ -41,11 +53,17 @@ internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountB
.padding(vertical = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemCurrencyIcon(
state = amountBlock.currencyIcon,
modifier = Modifier.size(72.dp),
)
AmountIcon(amountBlock.icon)
SpacerH(24.dp)
amountBlock.label?.let { label ->
Text(
text = label.resolveReference(),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.subheading.medium,
textAlign = TextAlign.Center,
)
SpacerH(4.dp)
}
Text(
text = amountBlock.amount.resolveReference(),
color = if (amountBlock.isFailed) {
@ -73,6 +91,60 @@ internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountB
}
}
@Composable
private fun AmountIcon(icon: TxHistoryDetailsUM.AmountIconUM, modifier: Modifier = Modifier) {
when (icon) {
is TxHistoryDetailsUM.AmountIconUM.Single -> AmountIconItem(
item = TxHistoryDetailsUM.AmountIconUM.Item.Currency(icon.currencyIcon),
modifier = modifier,
)
is TxHistoryDetailsUM.AmountIconUM.OverlappingPair -> Box(
modifier = modifier
.height(LeadingSize)
.width(PairRing + IconSize * 2 - PairOverlap),
) {
// Trailing behind, right; the pair drops the network badge so the two token arts read as equal-size icons.
AmountIconItem(
item = icon.trailing,
modifier = Modifier.align(Alignment.CenterEnd),
shouldDisplayNetwork = false,
)
// Leading on top, left, wrapped in a background-colored ring that separates it from the trailing icon.
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.size(LeadingSize)
.background(TangemTheme.colors3.bg.secondary, CircleShape)
.padding(PairRing),
) {
AmountIconItem(item = icon.leading, shouldDisplayNetwork = false)
}
}
}
}
@Composable
private fun AmountIconItem(
item: TxHistoryDetailsUM.AmountIconUM.Item,
modifier: Modifier = Modifier,
shouldDisplayNetwork: Boolean = true,
) {
when (item) {
is TxHistoryDetailsUM.AmountIconUM.Item.Currency -> TangemCurrencyIcon(
state = item.state,
modifier = modifier.size(IconSize),
shouldDisplayNetwork = shouldDisplayNetwork,
)
is TxHistoryDetailsUM.AmountIconUM.Item.Resource -> Image(
painter = painterResource(item.resId),
contentDescription = null,
modifier = modifier
.size(IconSize)
.clip(CircleShape),
)
}
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@ -87,21 +159,42 @@ private fun TxHistoryDetailsAmountBlockPreview() {
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = true))
// No fiat — the fiat line is omitted entirely.
TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false, fiatAmount = null))
// Yield supply — "Supplied" (Aave leads) and "Returned" (asset leads), unsigned amount.
TxHistoryDetailsAmountBlock(
amountBlock = previewAmountBlock(
isFailed = false,
fiatAmount = null,
icon = TxHistoryDetailsUM.AmountIconUM.OverlappingPair(
leading = TxHistoryDetailsUM.AmountIconUM.Item.Resource(R.drawable.img_aave_22),
trailing = TxHistoryDetailsUM.AmountIconUM.Item.Currency(previewCurrencyIcon()),
),
label = stringReference("Supplied"),
amount = stringReference("1,294.23 USDT"),
),
)
}
}
}
private fun previewAmountBlock(isFailed: Boolean, fiatAmount: TextReference? = stringReference("$350.31")) =
TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
amount = stringReference("+ 350.31 USDT"),
fiatAmount = fiatAmount,
isFailed = isFailed,
)
private fun previewCurrencyIcon() = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
)
private fun previewAmountBlock(
isFailed: Boolean,
fiatAmount: TextReference? = stringReference("$350.31"),
icon: TxHistoryDetailsUM.AmountIconUM = TxHistoryDetailsUM.AmountIconUM.Single(previewCurrencyIcon()),
label: TextReference? = null,
amount: TextReference = stringReference("+ 350.31 USDT"),
) = TxHistoryDetailsUM.AmountBlockUM(
icon = icon,
amount = amount,
label = label,
fiatAmount = fiatAmount,
isFailed = isFailed,
)
// endregion

View file

@ -78,11 +78,13 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
menu = previewMenu(),
),
amountBlock = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
icon = TxHistoryDetailsUM.AmountIconUM.Single(
CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
amount = stringReference("- 350.31 USDT"),
fiatAmount = stringReference("$350.31"),

View file

@ -17,6 +17,7 @@ import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ -207,6 +208,55 @@ internal class OnChainTxToDetailsUMConverterTest : TxDetailsConverterTestBase()
assertThat(amount).doesNotContain("-")
}
@Test
fun `GIVEN non-yield Transfer WHEN convert THEN single icon and no label`() {
// Arrange
val tx = txInfo(type = TransactionType.Transfer)
// Act
val amountBlock = converter.convert(tx).amountBlock
// Assert
assertThat(amountBlock.icon).isInstanceOf(TxHistoryDetailsUM.AmountIconUM.Single::class.java)
assertThat(amountBlock.label).isNull()
}
@Test
fun `GIVEN yield-supply Enter WHEN convert THEN Supplied label, Aave-leading pair and unsigned amount`() {
// Arrange — enter (funds outgoing to Aave): the amount must still be unsigned.
val tx = txInfo(type = TransactionType.YieldSupply.Enter(address = USER_ADDRESS), isOutgoing = true)
// Act
val amountBlock = converter.convert(tx).amountBlock
// Assert
assertThat(amountBlock.label).isEqualTo(resourceReference(R.string.yield_module_transaction_supplied))
val pair = amountBlock.icon as TxHistoryDetailsUM.AmountIconUM.OverlappingPair
assertThat(pair.leading).isEqualTo(TxHistoryDetailsUM.AmountIconUM.Item.Resource(R.drawable.img_aave_22))
assertThat(pair.trailing).isInstanceOf(TxHistoryDetailsUM.AmountIconUM.Item.Currency::class.java)
val amount = amountBlock.amount.resolveString()
assertThat(amount).doesNotContain("+")
assertThat(amount).doesNotContain("-")
}
@Test
fun `GIVEN yield-supply Exit WHEN convert THEN Returned label, asset-leading pair and unsigned amount`() {
// Arrange
val tx = txInfo(type = TransactionType.YieldSupply.Exit(address = USER_ADDRESS), isOutgoing = false)
// Act
val amountBlock = converter.convert(tx).amountBlock
// Assert
assertThat(amountBlock.label).isEqualTo(resourceReference(R.string.yield_module_transaction_returned))
val pair = amountBlock.icon as TxHistoryDetailsUM.AmountIconUM.OverlappingPair
assertThat(pair.leading).isInstanceOf(TxHistoryDetailsUM.AmountIconUM.Item.Currency::class.java)
assertThat(pair.trailing).isEqualTo(TxHistoryDetailsUM.AmountIconUM.Item.Resource(R.drawable.img_aave_22))
val amount = amountBlock.amount.resolveString()
assertThat(amount).doesNotContain("+")
assertThat(amount).doesNotContain("-")
}
// endregion
// region Counterparty
@ -223,6 +273,29 @@ internal class OnChainTxToDetailsUMConverterTest : TxDetailsConverterTestBase()
assertThat(counterparty).isNull()
}
@ParameterizedTest
@MethodSource("provideContractInteractionTypes")
fun `GIVEN contract-interaction tx with User interaction address WHEN convert THEN no counterparty card`(
type: TransactionType,
) {
// Arrange — yield-supply / staking / approve talk to a protocol/validator, not a copyable recipient.
val tx = txInfo(type = type, interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS))
// Act
val counterparty = converter.convert(tx).counterparty
// Assert
assertThat(counterparty).isNull()
}
private fun provideContractInteractionTypes() = listOf(
TransactionType.YieldSupply.Enter(address = USER_ADDRESS),
TransactionType.YieldSupply.Exit(address = USER_ADDRESS),
TransactionType.Staking.Stake,
TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS),
TransactionType.Approve,
)
@Test
fun `GIVEN incoming Transfer with User address WHEN convert THEN address-avatar counterparty with From label`() {
// Arrange
@ -435,8 +508,8 @@ internal class OnChainTxToDetailsUMConverterTest : TxDetailsConverterTestBase()
// region Protocol row (yield-supply)
@Test
fun `GIVEN yield-supply tx WHEN convert THEN protocol row shows the hard-wired Aave protocol`() {
// Arrange — yield-supply is a single hard-wired integration (Aave), so the value is constant, not resolved.
fun `GIVEN yield-supply tx WHEN convert THEN protocol row shows the hard-wired Aave protocol with its link`() {
// Arrange — yield-supply is a single hard-wired integration (Aave), so the value and link are constant.
val tx = txInfo(type = TransactionType.YieldSupply.Enter(address = USER_ADDRESS))
// Act
@ -445,8 +518,9 @@ internal class OnChainTxToDetailsUMConverterTest : TxDetailsConverterTestBase()
// Assert
assertThat(row.label).isEqualTo(resourceReference(R.string.staking_validator))
assertThat(row.value).isEqualTo(resourceReference(R.string.yield_module_provider))
assertThat(row.trailingIconRes).isNull()
assertThat(row.onClick).isNull()
assertThat(row.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24)
row.onClick?.invoke()
assertThat(openedUrls).containsExactly("https://aave.com/")
}
@Test