Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-19 13:28:10 +02:00
commit 39635f2b3d
8 changed files with 805 additions and 14 deletions

View file

@ -14,11 +14,13 @@ 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.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
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 kotlinx.collections.immutable.persistentListOf
import org.joda.time.DateTime
/**
@ -37,13 +39,18 @@ internal class TxInfoToTxHistoryDetailsUMConverter(
private val iconStateConverter = CryptoCurrencyToIconStateConverter()
override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) {
is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(header = value.toHeaderUM())
// TODO([REDACTED_TASK_KEY]): populate `from` / `to` legs once TxInfo exposes the swap legs (amounts, currencies, fiat).
// Until then the card falls back to the header-only placeholder (the TwoAssetsBlock UI is already wired).
is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(
header = value.toHeaderUM(),
statusBanner = value.toStatusBannerUM(),
)
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(),
rows = persistentListOf(),
)
}
@ -54,6 +61,31 @@ internal class TxInfoToTxHistoryDetailsUMConverter(
subtitle = headerSubtitle(),
)
/**
* Express status plaque under the swap block. A stopgap over the three generic [TxInfo.TransactionStatus] values
* so [Severity.Warning] (verification) is not reachable yet.
*
* [REDACTED_TODO_COMMENT]
*/
private fun TxInfo.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (status) {
is TxInfo.TransactionStatus.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
)
is TxInfo.TransactionStatus.Confirmed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
)
is TxInfo.TransactionStatus.Failed -> TxHistoryDetailsUM.StatusBannerUM(
severity = Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
)
}
private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM(
currencyIcon = iconStateConverter.convert(currency),
amount = stringReference(signedAmount(currency)),

View file

@ -8,6 +8,7 @@ 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
import kotlinx.collections.immutable.ImmutableList
/**
* UI model for the in-app transaction details ("Operation") card.
@ -28,14 +29,79 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
override val header: HeaderUM,
val amountBlock: AmountBlockUM,
val counterparty: CounterpartyUM?,
val rows: List<InfoRowUM>,
val rows: ImmutableList<InfoRowUM>,
) : TxHistoryDetailsUM
/** Two-asset layout: Swap / Onramp */
/**
* Two-asset layout: Swap / Onramp.
*
* [from] ("You sent") [to] ("You receive") exchange block. Both are nullable: the converter can't populate the
* legs yet (`TxInfo` exposes no swap amounts/currencies/fiat), so the card falls back to a header-only placeholder
* until that data lands. [statusBanner] is the express status plaque under the block, `null` until status is known.
*/
data class TwoAssets(
override val header: HeaderUM,
val from: AssetUM? = null,
val to: AssetUM? = null,
val statusBanner: StatusBannerUM? = null,
) : TxHistoryDetailsUM
/**
* Express status plaque under the two-asset block. The UI animates between successive emissions.
*
* @property severity Plaque colors (background tint + text/icon color).
* @property title Status line, e.g. "Awaiting funds" / "Confirmed" / "Failed".
* @property subtitle Optional second line (e.g. the refund hint on a failed terminal).
* @property isLoading `true` trailing rotating loader (in-progress); `false` static [severity] glyph.
*/
data class StatusBannerUM(
val severity: Severity,
val title: TextReference,
val subtitle: TextReference? = null,
val isLoading: Boolean,
) {
/** Visual severity of the [StatusBannerUM] — selects the background tint and the text/icon color. */
enum class Severity { Info, Success, Error, Warning }
}
/**
* One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing
* side. [owner] `null` plain label ("You sent"); non-null "From"/"To" prefix plus the resolved own account /
* wallet decoration. [isFaded] renders the unsettled/failed amount (struck through, recolored to tertiary).
*/
data class AssetUM(
val label: TextReference,
val owner: AssetOwnerUM?,
val amount: TextReference,
val currencyIcon: CurrencyIconState,
val isFaded: Boolean,
)
/**
* 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]).
*/
@Immutable
sealed interface AssetOwnerUM {
val name: TextReference
/** User's own account — the [iconResId] glyph tinted over [backgroundColor], shown **before** the [name]. */
data class Account(
override val name: TextReference,
@DrawableRes val iconResId: Int,
val backgroundColor: Color,
) : AssetOwnerUM
/** User's own wallet — the wallet card [deviceIconUM], shown **after** the [name]. */
data class Wallet(
override val name: TextReference,
val deviceIconUM: DeviceIconUM,
) : AssetOwnerUM
}
/**
* Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto
* [amount] and the secondary [fiatAmount].
@ -43,7 +109,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* [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,
@ -55,7 +120,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* 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,
@ -76,7 +140,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* @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,
@ -105,7 +168,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent {
* 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,

View file

@ -19,8 +19,7 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM
internal fun TxHistoryDetailsContent(state: TxHistoryDetailsUM, modifier: Modifier = Modifier) {
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)
is TxHistoryDetailsUM.TwoAssets -> TwoAssetsContent(state = state, modifier = modifier)
}
}
@ -45,6 +44,35 @@ private fun SingleAssetContent(state: TxHistoryDetailsUM.SingleAsset, modifier:
}
}
@Composable
private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) {
val from = state.from
val to = state.to
Column(modifier = modifier.fillMaxWidth().padding(bottom = 16.dp)) {
if (from != null && to != null) {
TxHistoryDetailsTwoAssetsBlock(
from = from,
to = to,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
)
} else {
// TODO([REDACTED_TASK_KEY]): the converter cannot populate the swap legs yet (TxInfo exposes no two-leg / fiat /
// provider data). Until those fields land, fall back to the header-only placeholder.
TwoAssetsPlaceholder(state = state)
}
// Express status plaque under the exchange block. The top gap is owned by the banner (inside its collapsing
// region), so only horizontal padding is applied here.
TxHistoryDetailsStatusBanner(
state = state.statusBanner,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
}
@Composable
private fun TwoAssetsPlaceholder(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) {
Box(

View file

@ -20,6 +20,8 @@ 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
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Info-rows block of the transaction details card: a vertical list of DS3 [TangemRow]s (label on the leading side,
@ -36,7 +38,7 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM
* @param modifier Modifier applied to the list container.
*/
@Composable
internal fun TxHistoryDetailsInfoRows(rows: List<InfoRowUM>, modifier: Modifier = Modifier) {
internal fun TxHistoryDetailsInfoRows(rows: ImmutableList<InfoRowUM>, modifier: Modifier = Modifier) {
if (rows.isEmpty()) return
Column(
modifier = modifier,
@ -74,7 +76,7 @@ private fun TxHistoryDetailsInfoRowsPreview() {
) {
// Multiple rows — dividers between rows, none after the last
TxHistoryDetailsInfoRows(
rows = listOf(
rows = persistentListOf(
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")),
@ -83,7 +85,7 @@ private fun TxHistoryDetailsInfoRowsPreview() {
// Single row — no divider
TxHistoryDetailsInfoRows(
modifier = Modifier.padding(top = 16.dp),
rows = listOf(
rows = persistentListOf(
InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)

View file

@ -14,6 +14,7 @@ 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
import kotlinx.collections.immutable.persistentListOf
/**
* The transaction details bottom sheet ("Operation"): the [TangemModalBottomSheet] shell shared by all transaction
@ -79,7 +80,7 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset(
),
onCopyClick = {},
),
rows = listOf(
rows = persistentListOf(
TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")),
),
)

View file

@ -0,0 +1,311 @@
package com.tangem.features.txhistory.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.extensions.TextReference
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.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_error_20
import com.tangem.core.ui.res.generated.icons.ic_info_20
import com.tangem.core.ui.res.generated.icons.ic_success_20
import com.tangem.core.ui.res.generated.icons.ic_warning_20
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM
import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity
// Animation timings in ms (ProtoPie spec). The status swap is two-phase: the old status fades out, then the new one
// fades/slides in after ENTER_DELAY. Most steps run over the default duration; the trailing loader/glyph fades faster
// (FAST_FADE), and the plaque grows over GROW to make room for a subtitle.
private const val DEFAULT_ANIMATION_MILLIS = 300
private const val FAST_FADE_MILLIS = 200
private const val GROW_MILLIS = 400
private const val ENTER_DELAY_MILLIS = DEFAULT_ANIMATION_MILLIS // phase 2 waits for the phase-1 fade-out to clear
private const val SUBTITLE_DELAY_MILLIS = ENTER_DELAY_MILLIS + 100 // subtitle trails the title
private const val TITLE_SLIDE_FRACTION = 12 // in-progress/Success title slides in 1/12 width from the right
private const val CONTENT_RISE_FRACTION = 2 // Warning/Error title floats up 1/2 height from below
private const val ICON_ENTER_SCALE = 0.6f
/** Gap between the exchange block above and the plaque; kept inside the collapsing region so it folds away cleanly. */
private val BANNER_TOP_GAP = 12.dp
/** Gap between the title row and the subtitle; lives inside the subtitle slot so it folds away when there's no line. */
private val SUBTITLE_TOP_GAP = 4.dp
/** Key for the title [AnimatedContent]: the resolved [text] plus the [severity] that selects the swap motion. */
private data class StatusBannerTitle(val text: String, val severity: Severity)
/**
* Title transition picked by the *target* severity: Info/Success slide in from the right ([titleSlide]); Warning/Error
* float up from below ([titleRise]). Both fade the old status out fully before fading the new one in.
*/
private fun titleTransition(target: Severity): ContentTransform = when (target) {
Severity.Warning, Severity.Error -> titleRise()
Severity.Info, Severity.Success -> titleSlide()
}
/** In-progress / success swap: old status fades out, new one fades in sliding from the right. */
private fun titleSlide(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
slideInHorizontally(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
) { width -> width / TITLE_SLIDE_FRACTION },
initialContentExit = fadeOut(tween(durationMillis = DEFAULT_ANIMATION_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/** Terminal warning / error swap: old status fades out, new one fades in floating up a touch from below. */
private fun titleRise(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
slideInVertically(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
) { height -> height / CONTENT_RISE_FRACTION },
initialContentExit = fadeOut(tween(durationMillis = DEFAULT_ANIMATION_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/** Trailing-slot swap (loader → glyph): loader fades out (Phase 1), then the glyph "pops" in (Phase 2). */
private fun iconSwapTransition(): ContentTransform = ContentTransform(
targetContentEnter = fadeIn(tween(durationMillis = FAST_FADE_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) +
scaleIn(
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
initialScale = ICON_ENTER_SCALE,
),
initialContentExit = fadeOut(tween(durationMillis = FAST_FADE_MILLIS)),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
/**
* Express status plaque of the Swap / Onramp transaction details, rendered under the two-asset exchange block.
*
* [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1370-114172)
*
* Two animation layers: [AnimatedVisibility] grows the plaque in from its top edge / collapses it to the bottom;
* in-place status transitions ([StatusBannerContent]) morph the title, background tint and trailing loaderglyph as
* the model re-emits the latest [state].
*
* @param state Current status to render, or `null` to hide the plaque (animated out).
* @param modifier Modifier applied to the plaque container.
*/
@Composable
internal fun TxHistoryDetailsStatusBanner(state: StatusBannerUM?, modifier: Modifier = Modifier) {
// Retain the last non-null state so content stays rendered through the exit (collapse+fade). The retained value
// only backfills the exit (when [state] is null); published in a SideEffect, not written during composition.
val lastState = remember { mutableStateOf<StatusBannerUM?>(null) }
SideEffect { if (state != null) lastState.value = state }
val content = state ?: lastState.value
AnimatedVisibility(
visible = state != null,
// Fade and size share one tween so alpha and height finish together (mismatched default springs leave a jerk).
enter = fadeIn(tween(DEFAULT_ANIMATION_MILLIS)) +
expandVertically(tween(DEFAULT_ANIMATION_MILLIS), expandFrom = Alignment.Top),
exit = fadeOut(tween(DEFAULT_ANIMATION_MILLIS)) +
shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Bottom),
modifier = modifier,
) {
// Leading gap lives inside the animated region so it collapses together with the plaque (no residual margin).
content?.let { StatusBannerContent(state = it, modifier = Modifier.padding(top = BANNER_TOP_GAP)) }
}
}
@Composable
private fun StatusBannerContent(state: StatusBannerUM, modifier: Modifier = Modifier) {
val backgroundColor by animateColorAsState(
targetValue = state.severity.backgroundColor(),
// Delayed into Phase 2, so the tint starts shifting only once the old title has faded out, matching the spec.
animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
label = "StatusBannerBackground",
)
val contentColor = state.severity.contentColor()
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Animate the title as the status advances. Keyed on (text, severity) so [titleTransition] picks the motion
// by target; the key also colors each content from its own severity (see [color] below).
AnimatedContent(
targetState = StatusBannerTitle(state.title.resolveReference(), state.severity),
transitionSpec = { titleTransition(target = targetState.severity) },
label = "StatusBannerTitle",
modifier = Modifier.weight(1f),
) { title ->
Text(
text = title.text,
style = TangemTheme.typography3.body.medium,
// From this title's own key, so the outgoing title fades out in its colour instead of snapping.
color = title.severity.contentColor(),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
StatusBannerTrailing(isLoading = state.isLoading, severity = state.severity)
}
// Retain the last non-null subtitle so the line stays rendered while it fades out (mirrors the retain above).
val lastSubtitle = remember { mutableStateOf<TextReference?>(null) }
SideEffect { if (state.subtitle != null) lastSubtitle.value = state.subtitle }
AnimatedVisibility(
visible = state.subtitle != null,
// The subtitle owns the plaque's growth: expandVertically opens its slot in Phase 2, then the text fades in
// a touch later so it trails the title. expandVertically (not animateContentSize) lets us delay the growth.
enter = expandVertically(
animationSpec = tween(GROW_MILLIS, delayMillis = ENTER_DELAY_MILLIS),
expandFrom = Alignment.Top,
) + fadeIn(tween(DEFAULT_ANIMATION_MILLIS, delayMillis = SUBTITLE_DELAY_MILLIS)),
exit = shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Top) +
fadeOut(tween(DEFAULT_ANIMATION_MILLIS)),
) {
(state.subtitle ?: lastSubtitle.value)?.let { subtitle ->
Text(
text = subtitle.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = contentColor,
modifier = Modifier.padding(top = SUBTITLE_TOP_GAP),
)
}
}
}
}
/** Key for the trailing [AnimatedContent]: whether the loader or a glyph shows, plus the [severity] that tints it. */
private data class StatusBannerGlyph(val isLoading: Boolean, val severity: Severity)
/** Trailing slot: rotating loader while in progress, the static severity status glyph once terminal. */
@Composable
private fun StatusBannerTrailing(isLoading: Boolean, severity: Severity, modifier: Modifier = Modifier) {
// Keyed on (isLoading, severity) so the tint comes from each content's own key — the outgoing loader then fades
// out in its colour instead of snapping to the incoming status'.
AnimatedContent(
targetState = StatusBannerGlyph(isLoading, severity),
transitionSpec = { iconSwapTransition() },
label = "StatusBannerTrailing",
modifier = modifier,
) { glyph ->
val tint = glyph.severity.contentColor()
if (glyph.isLoading) {
TangemLoader(size = TangemLoaderSize.X20, color = tint)
} else {
Icon(
imageVector = glyph.severity.statusIcon(),
contentDescription = null,
tint = tint,
modifier = Modifier.size(20.dp),
)
}
}
}
@Composable
private fun Severity.backgroundColor(): Color = when (this) {
Severity.Info -> TangemTheme.colors3.bg.status.infoSubtle
Severity.Success -> TangemTheme.colors3.bg.status.successSubtle
Severity.Error -> TangemTheme.colors3.bg.status.errorSubtle
Severity.Warning -> TangemTheme.colors3.bg.status.warningSubtle
}
@Composable
private fun Severity.contentColor(): Color = when (this) {
Severity.Info -> TangemTheme.colors3.text.status.info
Severity.Success -> TangemTheme.colors3.text.status.success
Severity.Error -> TangemTheme.colors3.text.status.error
Severity.Warning -> TangemTheme.colors3.text.status.warning
}
private fun Severity.statusIcon() = when (this) {
Severity.Success -> Icons.ic_success_20
Severity.Error -> Icons.ic_error_20
Severity.Warning -> Icons.ic_warning_20
Severity.Info -> Icons.ic_info_20
}
// region Preview
@Preview(name = "Light", showBackground = true, widthDp = 360)
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360)
@Composable
private fun TxHistoryDetailsStatusBannerPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Info, stringReference("Awaiting funds"), isLoading = true),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Info, stringReference("Deposit confirmed"), isLoading = true),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(Severity.Success, stringReference("Confirmed"), isLoading = false),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(
severity = Severity.Error,
title = stringReference("Failed"),
subtitle = stringReference("Visit provider's website to refund your money"),
isLoading = false,
),
)
TxHistoryDetailsStatusBanner(
state = StatusBannerUM(
severity = Severity.Warning,
title = stringReference("Verification required"),
subtitle = stringReference("Visit provider's website to refund your money"),
isLoading = false,
),
)
}
}
}
// endregion

View file

@ -0,0 +1,300 @@
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.Row
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextDecoration
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.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.TextReference
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.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.
*
* [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1265-87546)
*
* @param from Sent ("You sent" / "From …") side.
* @param to Received ("You receive" / "To …") side.
* @param modifier Modifier applied to the block container.
*/
@Composable
internal fun TxHistoryDetailsTwoAssetsBlock(from: AssetUM, to: AssetUM, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.background(TangemTheme.colors3.bg.tertiary),
) {
Column(
modifier = Modifier.padding(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
TwoAssetsSideRow(asset = from)
DashedDivider()
TwoAssetsSideRow(asset = to)
}
// Centered exchange arrow. Both rows are equal-height, so the block center sits on the divider; the
// `bg.tertiary` chip behind the icon masks the dashed line, reproducing the Figma center gap.
Box(
modifier = Modifier
.align(Alignment.Center)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.tertiary)
.padding(4.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_down_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
modifier = Modifier.size(16.dp),
)
}
}
}
@Composable
private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) {
TangemRow(
modifier = modifier,
contentLead = TangemRowContentLead.Start,
verticalAlignment = TangemRowVerticalAlignment.Center,
titleSlot = { TwoAssetsSideLabel(label = asset.label, owner = asset.owner) },
subtitleSlot = {
Text(
text = asset.amount.resolveReference(),
style = TangemTheme.typography3.heading.small,
color = if (asset.isFaded) {
TangemTheme.colors3.text.tertiary
} else {
TangemTheme.colors3.text.primary
},
textDecoration = if (asset.isFaded) TextDecoration.LineThrough else null,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
},
endSlot = {
TangemCurrencyIcon(
state = asset.currencyIcon,
modifier = Modifier.size(40.dp),
)
},
)
}
/**
* 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.
*/
@Composable
private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LabelText(text = label)
when (owner) {
is AssetOwnerUM.Account -> {
AssetOwnerIcon(owner = owner)
LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false))
}
is AssetOwnerUM.Wallet -> {
LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false))
AssetOwnerIcon(owner = owner)
}
null -> Unit
}
}
}
@Composable
private fun LabelText(text: TextReference, modifier: Modifier = Modifier) {
Text(
text = text.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}
/** 16dp inline owner decoration: the account glyph over its color, or the wallet device card. */
@Composable
private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) {
val iconModifier = modifier.size(16.dp)
when (owner) {
is AssetOwnerUM.Account -> Box(
modifier = iconModifier
.clip(RoundedCornerShape(4.dp))
.background(owner.backgroundColor),
contentAlignment = Alignment.Center,
) {
Icon(
painter = painterResource(id = owner.iconResId),
contentDescription = null,
// staticDark == white in both themes (the constant glyph tone for a colored avatar), matching the
// white-on-color account avatar in Figma and the counterparty card / history-list account icon.
tint = TangemTheme.colors3.icon.staticDark,
modifier = Modifier.size(8.dp),
)
}
is AssetOwnerUM.Wallet -> TangemDeviceIcon(
state = owner.deviceIconUM,
modifier = iconModifier,
)
}
}
/** 1px inset dashed divider between the two sides, matching the Figma `divider` (dashed `line`). */
@Composable
private fun DashedDivider(modifier: Modifier = Modifier) {
val color = TangemTheme.colors3.border.tertiary
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(1.dp)
.drawBehind {
val stroke = 1.dp.toPx()
val y = size.height / 2f
drawLine(
color = color,
start = Offset(x = 0f, y = y),
end = Offset(x = size.width, y = y),
strokeWidth = stroke,
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(
intervals = floatArrayOf(2.dp.toPx(), 4.dp.toPx()),
),
)
},
)
}
// 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 TxHistoryDetailsTwoAssetsBlockPreview() {
TangemThemePreviewRedesign {
Column(
modifier = Modifier
.background(TangemTheme.colors3.bg.primary)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Plain swap (no resolved owner) — both sides settled.
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
to = previewAsset(label = "You receive", amount = "+ 1,800.00 POL", isFaded = false),
)
// Unsettled swap — the "You receive" side is struck through until the funds arrive.
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false),
to = previewAsset(label = "You receive", amount = "1,800.00 POL", isFaded = true),
)
// Account -> another account (own-to-own transfer between two of the user's accounts).
TxHistoryDetailsTwoAssetsBlock(
from = previewAsset(
label = "From",
amount = "- 390 USDT",
isFaded = false,
owner = AssetOwnerUM.Account(
name = stringReference("Main account"),
iconResId = R.drawable.ic_rounded_star_24,
backgroundColor = Color(0xFF007FFF),
),
),
to = previewAsset(
label = "To",
amount = "+ 1,800.00 POL",
isFaded = false,
owner = AssetOwnerUM.Account(
name = stringReference("Family"),
iconResId = R.drawable.ic_family_24,
backgroundColor = Color(0xFF744FF1),
),
),
)
// Wallet -> another wallet (own-to-own transfer between two of the user's wallets).
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.Wallet(
name = stringReference("My Wallet"),
deviceIconUM = DeviceIconUM.Ring(mainColor = Color(0xFF9F86FF)),
),
),
)
}
}
}
private fun previewAsset(label: String, amount: String, isFaded: Boolean, owner: AssetOwnerUM? = null) = AssetUM(
label = stringReference(label),
owner = owner,
amount = stringReference(amount),
currencyIcon = CurrencyIconState.CoinIcon(
url = null,
fallbackResId = R.drawable.img_eth_22,
isGrayscale = false,
shouldShowCustomBadge = false,
),
isFaded = isFaded,
)
// endregion

View file

@ -102,6 +102,61 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest {
assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24)
}
@Test
fun `GIVEN unconfirmed Swap WHEN convert THEN info status banner with loader`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Unconfirmed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info,
title = resourceReference(R.string.express_exchange_status_receiving_active),
isLoading = true,
),
)
}
@Test
fun `GIVEN confirmed Swap WHEN convert THEN success status banner without loader`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Confirmed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success,
title = resourceReference(R.string.express_exchange_status_exchanged),
isLoading = false,
),
)
}
@Test
fun `GIVEN failed Swap WHEN convert THEN error status banner with refund subtitle`() {
// Arrange
val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Failed)
// Act
val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner
// Assert
assertThat(banner).isEqualTo(
TxHistoryDetailsUM.StatusBannerUM(
severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error,
title = resourceReference(R.string.express_exchange_status_failed),
subtitle = resourceReference(R.string.express_exchange_notification_failed_text),
isLoading = false,
),
)
}
@Test
fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() {
// Arrange