From 0fc2462b96c694e1c94a1dbcfd3b29505af9850a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 18:37:12 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../TxInfoToTxHistoryDetailsUMConverter.kt | 36 +- .../txhistory/entity/TxHistoryDetailsUM.kt | 74 ++++- .../txhistory/ui/TxHistoryDetailsContent.kt | 32 +- .../txhistory/ui/TxHistoryDetailsInfoRows.kt | 8 +- ...TxHistoryDetailsModalBottomSheetContent.kt | 3 +- .../ui/TxHistoryDetailsStatusBanner.kt | 311 ++++++++++++++++++ .../ui/TxHistoryDetailsTwoAssetsBlock.kt | 300 +++++++++++++++++ ...TxInfoToTxHistoryDetailsUMConverterTest.kt | 55 ++++ 8 files changed, 805 insertions(+), 14 deletions(-) create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt index 94be545f70..70bf982a6d 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt @@ -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)), diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index 953290ca5d..3b3464e998 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -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, + val rows: ImmutableList, ) : 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, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt index 168b677494..236d3ffeca 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt @@ -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( diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt index 832800759a..9a9a742589 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt @@ -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, modifier: Modifier = Modifier) { +internal fun TxHistoryDetailsInfoRows(rows: ImmutableList, 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")), ), ) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt index 62309f8e01..bf58d7e39c 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt @@ -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")), ), ) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt new file mode 100644 index 0000000000..3c8eb60f34 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt @@ -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 loader→glyph 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(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(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 \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt new file mode 100644 index 0000000000..62e9dc2a16 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt @@ -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 \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt index 7d7eb52f06..2ddf7a9b3a 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt @@ -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