diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt index f460a6f129..1b575284be 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateTimeFormatters.kt @@ -77,6 +77,13 @@ object DateTimeFormatters { getBestFormatterBySkeleton("MMM dd") } + /** + * Example: "Jun 1, 2020", "1 Jun 2020" + */ + val dateMMMdYYYY: DateTimeFormatter by lazy { + getBestFormatterBySkeleton("MMM d, yyyy") + } + /** * Example: "2020" */ diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 65e102ca06..43a54f613d 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { implementation(deps.decompose.ext.compose) /* Tests */ + testImplementation(projects.common.test) testImplementation(deps.test.junit5) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryDetailsComponent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryDetailsComponent.kt index 12db42a758..8d553130b3 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryDetailsComponent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryDetailsComponent.kt @@ -5,14 +5,8 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.R -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet -import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle -import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.features.txhistory.model.TxHistoryDetailsModel -import com.tangem.features.txhistory.ui.TxHistoryDetailsContent +import com.tangem.features.txhistory.ui.TxHistoryDetailsModalBottomSheetContent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -32,17 +26,7 @@ internal class DefaultTxHistoryDetailsComponent @AssistedInject constructor( override fun BottomSheet() { val state by model.uiState.collectAsStateWithLifecycle() - TangemModalBottomSheet( - config = TangemBottomSheetConfig( - isShown = state != null, - onDismissRequest = ::dismiss, - content = state ?: TangemBottomSheetConfigContent.Empty, - ), - title = { - TangemModalBottomSheetTitle(endIconRes = R.drawable.ic_close_24, onEndClick = ::dismiss) - }, - content = { um -> TxHistoryDetailsContent(state = um) }, - ) + TxHistoryDetailsModalBottomSheetContent(state = state, onDismiss = ::dismiss) } @AssistedFactory 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 9b19315abb..94be545f70 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 @@ -1,9 +1,25 @@ package com.tangem.features.txhistory.converter +import androidx.annotation.StringRes +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import com.tangem.features.txhistory.impl.R +import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import com.tangem.utils.toBriefAddressFormat +import org.joda.time.DateTime /** * Converts a [TxInfo] to a [TxHistoryDetailsUM] for the in-app transaction details card. @@ -13,18 +29,114 @@ import com.tangem.utils.converter.Converter * - [TransactionType.Swap] (and onramp once it lands in `TxInfo`) -> [TxHistoryDetailsUM.TwoAssets] * - everything else -> [TxHistoryDetailsUM.SingleAsset] */ -internal class TxInfoToTxHistoryDetailsUMConverter : Converter { +internal class TxInfoToTxHistoryDetailsUMConverter( + private val currency: CryptoCurrency, + private val onCopyAddress: (String) -> Unit, +) : Converter { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) { - is TransactionType.Swap -> twoAssets(value) - else -> singleAsset(value) + is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(header = value.toHeaderUM()) + else -> TxHistoryDetailsUM.SingleAsset( + header = value.toHeaderUM(), + amountBlock = value.toAmountBlockUM(), + counterparty = value.toCounterpartyUM(), + // TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo. + rows = emptyList(), + ) } - private fun singleAsset(tx: TxInfo): TxHistoryDetailsUM.SingleAsset = TxHistoryDetailsUM.SingleAsset( - title = tx.type.toString(), + private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( + iconRes = headerIcon(), + status = status.toUiStatus(), + title = headerTitle(), + subtitle = headerSubtitle(), ) - private fun twoAssets(tx: TxInfo): TxHistoryDetailsUM.TwoAssets = TxHistoryDetailsUM.TwoAssets( - title = tx.type.toString(), + private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( + currencyIcon = iconStateConverter.convert(currency), + amount = stringReference(signedAmount(currency)), + // TODO: TxInfo has no fiat amount yet — placeholder until the fiat field is added to TxInfo. + fiatAmount = stringReference("\$0.00"), + isFailed = status is TxInfo.TransactionStatus.Failed, ) -} \ No newline at end of file + + /** + * Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced — built from + * the `User` interaction address (the same source the history list uses for its external-address subtitle). + * + * The own-account / own-wallet avatars ([TxHistoryDetailsUM.CounterpartyAvatar.Account] / `Wallet`) require the + * address->owner lookup the list assembles in `TxHistoryLookupContext`; wiring that into the detail model is a + * follow-up, so for now a counterparty that is not a plain external `User` address yields no card (`null`). + */ + private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? { + val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null + return TxHistoryDetailsUM.CounterpartyUM( + label = counterpartyLabel(), + title = stringReference(address.toBriefAddressFormat()), + avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address), + onCopyClick = { onCopyAddress(address) }, + ) + } + + /** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */ + private fun TxInfo.counterpartyLabel(): TextReference = + if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from) +} + +// region Amount building helpers + +/** + * Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+` + * otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) — the UI then only + * strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed]. + */ +private fun TxInfo.signedAmount(currency: CryptoCurrency): String { + val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) } + val prefix = when { + status is TxInfo.TransactionStatus.Failed -> "" + amount.isZero() -> "" + isOutgoing -> "${StringsSigns.MINUS} " + else -> "${StringsSigns.PLUS} " + } + return (prefix + formatted).trim() +} + +// endregion + +// region Header building helpers + +/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */ +private fun TxInfo.headerIcon(): Int = when (type) { + is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24 + else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 +} + +private fun TxInfo.headerTitle(): TextReference = when (type) { + is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped) + is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred) + else -> stringReference(type.toString()) +} + +private fun TxInfo.headerSubtitle(): TextReference { + val dateTime = DateTime(timestampInMillis) + val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime) + val time = DateTimeFormatters.timeFormatter.print(dateTime) + return stringReference("$date, $time") +} + +private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) { + is TxInfo.TransactionStatus.Failed -> + resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) + is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending) + is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed) +} + +private fun TxInfo.TransactionStatus.toUiStatus(): TransactionItemUM.Content.Status = when (this) { + TxInfo.TransactionStatus.Confirmed -> TransactionItemUM.Content.Status.Confirmed + TxInfo.TransactionStatus.Failed -> TransactionItemUM.Content.Status.Failed + TxInfo.TransactionStatus.Unconfirmed -> TransactionItemUM.Content.Status.Unconfirmed +} + +// endregion \ No newline at end of file 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 27754f168f..953290ca5d 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 @@ -1,7 +1,13 @@ package com.tangem.features.txhistory.entity +import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.extensions.TextReference /** * UI model for the in-app transaction details ("Operation") card. @@ -14,16 +20,96 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent @Immutable internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { - /** Operation title, status-driven color is resolved at render time. */ - val title: String + /** Shared top bar ("Nav bar"): type icon, status-driven title, date+time. */ + val header: HeaderUM /** Single-asset layout: Receive / Send / Transfer */ data class SingleAsset( - override val title: String, + override val header: HeaderUM, + val amountBlock: AmountBlockUM, + val counterparty: CounterpartyUM?, + val rows: List, ) : TxHistoryDetailsUM /** Two-asset layout: Swap / Onramp */ data class TwoAssets( - override val title: String, + override val header: HeaderUM, ) : TxHistoryDetailsUM + + /** + * Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto + * [amount] and the secondary [fiatAmount]. + * + * [isFailed] drives the failed visual state — the amount is struck through, recolored to tertiary and carries no + * `+`/`−` sign (mirrors the status-driven recolor in the shared header). + */ + @Immutable + data class AmountBlockUM( + val currencyIcon: CurrencyIconState, + val amount: TextReference, + val fiatAmount: TextReference, + val isFailed: Boolean, + ) + + /** + * A single info row of the details card: a [label] on the leading side and its [value] on the trailing side + * (e.g. `Network fee` → `0.00056 ETH`, `Rate` → `1 POL ≈ 0.36 USDT`). Rendered by [TxHistoryDetailsInfoRows]. + */ + @Immutable + data class InfoRowUM( + val label: TextReference, + val value: TextReference, + ) + + /** + * Counterparty ("Recipient" / "From") card of the single-asset detail: a leading [avatar], the section [label] over + * the counterparty [title], and — when [onCopyClick] is non-null — a trailing copy button. + * + * The layout is identical across counterparty kinds; the only variance is the [avatar] (see [CounterpartyAvatar]) + * and whether copy is offered. Only the [CounterpartyAvatar.Address] kind is currently produced by + * [com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet + * avatars are populated in a follow-up, once the detail model assembles the same address->owner lookup the list + * uses (`TxHistoryLookupContext`). + * + * @property label Section label above the counterparty: "Recipient" (outgoing) / "From" (incoming). + * @property title Counterparty value: brief address / account name / wallet name. + * @property avatar Leading avatar. + * @property onCopyClick Copy action; `null` hides the copy button (e.g. own-wallet has nothing to copy). + */ + @Immutable + data class CounterpartyUM( + val label: TextReference, + val title: TextReference, + val avatar: CounterpartyAvatar, + val onCopyClick: (() -> Unit)?, + ) + + /** Leading avatar of the [CounterpartyUM] card — the only thing that differs between counterparty kinds. */ + @Immutable + sealed interface CounterpartyAvatar { + + /** External blockchain address — rendered as an identicon generated from [rawAddress]. */ + data class Address(val rawAddress: String) : CounterpartyAvatar + + /** User's own account — rendered as [iconResId] tinted over [backgroundColor]. */ + data class Account( + @DrawableRes val iconResId: Int, + val backgroundColor: Color, + ) : CounterpartyAvatar + + /** User's own wallet — rendered as the wallet card [deviceIconUM]. */ + data class Wallet(val deviceIconUM: DeviceIconUM) : CounterpartyAvatar + } + + /** + * Shared bottom-sheet top bar. The icon glyph and [title] text come from the transaction type; [status] drives + * the three visual states (in-progress / confirmed / failed) — recoloring the icon circle and the title. + */ + @Immutable + data class HeaderUM( + @DrawableRes val iconRes: Int, + val status: TransactionItemUM.Content.Status, + val title: TextReference, + val subtitle: TextReference, + ) } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt index 1fa4172f63..b6f45b5318 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.features.txhistory.component.TxHistoryDetailsComponent import com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter import com.tangem.features.txhistory.entity.TxHistoryDetailsUM @@ -19,15 +20,24 @@ import javax.inject.Inject @ModelScoped internal class TxHistoryDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + private val clipboardManager: ClipboardManager, paramsContainer: ParamsContainer, ) : Model() { private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() - private val converter = TxInfoToTxHistoryDetailsUMConverter() + private val converter = TxInfoToTxHistoryDetailsUMConverter( + currency = params.currency, + onCopyAddress = ::onCopyAddress, + ) val uiState: StateFlow = params.txInfo .map(converter::convert) .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null) + + /** Copies a counterparty address to the clipboard — wired into the detail card's copy button via the converter. */ + private fun onCopyAddress(address: String) { + clipboardManager.setText(text = address, isSensitive = false) + } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt new file mode 100644 index 0000000000..39ec21f76e --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt @@ -0,0 +1,101 @@ +package com.tangem.features.txhistory.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM + +/** + * Centered amount block of the single-asset card: token avatar (with network badge) over the big signed amount and the + * secondary fiat line. + * + * The failed state ([TxHistoryDetailsUM.AmountBlockUM.isFailed]) strikes the amount through and dims it (primary -> + * secondary) — matching the status-driven recolor of the shared header. The `+`/`−` sign is already dropped upstream + * by the converter for failed transactions (a failed tx moved nothing), so the [amount] text arrives unsigned here. + */ +@Composable +internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountBlockUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TangemCurrencyIcon( + state = amountBlock.currencyIcon, + modifier = Modifier.size(72.dp), + ) + SpacerH(24.dp) + Text( + text = amountBlock.amount.resolveReference(), + color = if (amountBlock.isFailed) { + TangemTheme.colors3.text.secondary + } else { + TangemTheme.colors3.text.primary + }, + style = TangemTheme.typography3.heading.medium, + textAlign = TextAlign.Center, + textDecoration = if (amountBlock.isFailed) TextDecoration.LineThrough else null, + ) + SpacerH(4.dp) + Text( + text = amountBlock.fiatAmount.resolveReference(), + color = if (amountBlock.isFailed) { + TangemTheme.colors3.text.tertiary + } else { + TangemTheme.colors3.text.secondary + }, + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.Center, + ) + } +} + +// region Preview + +@Preview(name = "Light", showBackground = true, widthDp = 360) +@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360) +@Composable +private fun TxHistoryDetailsAmountBlockPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier.background(TangemTheme.colors3.bg.primary), + ) { + TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false)) + TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = true)) + } + } +} + +private fun previewAmountBlock(isFailed: Boolean) = TxHistoryDetailsUM.AmountBlockUM( + currencyIcon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_eth_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + amount = stringReference("+ 350.31 USDT"), + fiatAmount = stringReference("$350.31"), + isFailed = isFailed, +) + +// endregion \ No newline at end of file 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 85a775bf7b..168b677494 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 @@ -1,7 +1,7 @@ package com.tangem.features.txhistory.ui -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -11,24 +11,53 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.txhistory.entity.TxHistoryDetailsUM @Composable internal fun TxHistoryDetailsContent(state: TxHistoryDetailsUM, modifier: Modifier = Modifier) { - // Placeholder:card showing only the operation title, to verify tap -> sheet navigation + when (state) { + is TxHistoryDetailsUM.SingleAsset -> SingleAssetContent(state = state, modifier = modifier) + // TODO([REDACTED_TASK_KEY]): two-asset (Swap / Onramp) body — out of scope for the single-asset amount block ticket. + is TxHistoryDetailsUM.TwoAssets -> TwoAssetsPlaceholder(state = state, modifier = modifier) + } +} + +@Composable +private fun SingleAssetContent(state: TxHistoryDetailsUM.SingleAsset, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + TxHistoryDetailsAmountBlock(amountBlock = state.amountBlock) + state.counterparty?.let { counterparty -> + TxHistoryDetailsCounterpartyRow( + counterparty = counterparty, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + } + TxHistoryDetailsInfoRows( + rows = state.rows, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + } +} + +@Composable +private fun TwoAssetsPlaceholder(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxWidth() - .background(TangemTheme.colors2.surface.level2) .heightIn(min = 240.dp) - .padding(TangemTheme.dimens2.x6), + .padding(24.dp), contentAlignment = Alignment.Center, ) { Text( - text = state.title, - color = TangemTheme.colors2.text.neutral.primary, - style = TangemTheme.typography2.headingSemibold28, + text = state.header.title.resolveReference(), + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.heading.medium, textAlign = TextAlign.Center, ) } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsCounterpartyRow.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsCounterpartyRow.kt new file mode 100644 index 0000000000..18f73922fe --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsCounterpartyRow.kt @@ -0,0 +1,160 @@ +package com.tangem.features.txhistory.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.icons.identicon.IdentIcon +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.image.TangemDeviceIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowContentLead +import com.tangem.core.ui.ds2.row.TangemRowText +import com.tangem.core.ui.ds2.row.TangemRowTextRole +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_copy_20 +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.CounterpartyAvatar +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.CounterpartyUM +import com.tangem.features.txhistory.impl.R + +/** + * Counterparty ("Recipient" / "From") card of the single-asset detail, built on the DS3 [TangemRow] inside a tinted + * `bg.opaque.primary` cell. The layout is identical across counterparty kinds — only the leading + * [avatar][CounterpartyUM.avatar] varies (see [CounterpartyAvatar]) and the trailing copy button is shown only when + * [CounterpartyUM.onCopyClick] is non-null. + * + * The section [label][CounterpartyUM.label] sits above the counterparty value. [TangemRow] renders its `titleSlot` + * above the `subtitleSlot`, so the slots are filled inverted to their semantic role: the small caption label goes in + * the (upper) title slot and the body-sized value goes in the (lower) subtitle slot. + * + * @param counterparty Counterparty data driving the avatar, labels and the copy action. + * @param modifier Modifier applied to the cell container. + */ +@Composable +internal fun TxHistoryDetailsCounterpartyRow(counterparty: CounterpartyUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors3.bg.opaque.primary), + ) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + contentLead = TangemRowContentLead.Start, + startSlot = { CounterpartyAvatar(counterparty.avatar) }, + titleSlot = { TangemRowText(text = counterparty.label, role = TangemRowTextRole.Subtitle) }, + subtitleSlot = { TangemRowText(text = counterparty.title, role = TangemRowTextRole.Title) }, + endSlot = counterparty.onCopyClick?.let { onCopyClick -> + { CounterpartyCopyButton(onClick = onCopyClick) } + }, + ) + } +} + +@Composable +private fun CounterpartyAvatar(avatar: CounterpartyAvatar, modifier: Modifier = Modifier) { + val avatarModifier = modifier.size(40.dp) + when (avatar) { + is CounterpartyAvatar.Address -> IdentIcon( + address = avatar.rawAddress, + modifier = avatarModifier.clip(CircleShape), + ) + is CounterpartyAvatar.Account -> Box( + modifier = avatarModifier + .clip(CircleShape) + .background(avatar.backgroundColor), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = avatar.iconResId), + contentDescription = null, + tint = TangemTheme.colors3.icon.staticDark, + modifier = Modifier.size(20.dp), + ) + } + is CounterpartyAvatar.Wallet -> TangemDeviceIcon( + state = avatar.deviceIconUM, + modifier = avatarModifier, + ) + } +} + +@Composable +private fun CounterpartyCopyButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + TangemButton( + modifier = modifier, + variant = TangemButton.Variant.Secondary, + size = TangemButton.Size.X9, + iconStart = TangemIconUM.Icon(Icons.ic_copy_20), + contentDescription = resourceReference(R.string.common_copy).resolveReference(), + onClick = onClick, + ) +} + +// region Preview + +@Suppress("MagicNumber") +@Preview(name = "Light", showBackground = true, widthDp = 360) +@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360) +@Composable +private fun TxHistoryDetailsCounterpartyRowPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier.background(TangemTheme.colors3.bg.primary).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TxHistoryDetailsCounterpartyRow( + counterparty = CounterpartyUM( + label = stringReference("Recipient"), + title = stringReference("33Bd321fS...ga21412B"), + avatar = CounterpartyAvatar.Address(rawAddress = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359"), + onCopyClick = {}, + ), + ) + TxHistoryDetailsCounterpartyRow( + counterparty = CounterpartyUM( + label = stringReference("Recipient"), + title = stringReference("Danil Kolbasenko"), + avatar = CounterpartyAvatar.Account( + iconResId = R.drawable.ic_arrow_down_24, + backgroundColor = Color(0xFF704AF1), + ), + onCopyClick = {}, + ), + ) + TxHistoryDetailsCounterpartyRow( + counterparty = CounterpartyUM( + label = stringReference("Recipient"), + title = stringReference("Tangem wallet"), + avatar = CounterpartyAvatar.Wallet( + deviceIconUM = DeviceIconUM.Card(mainColor = Color(0xFF1E1E1E), secondColor = null), + ), + onCopyClick = null, + ), + ) + } + } +} + +// endregion \ No newline at end of file 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 new file mode 100644 index 0000000000..832800759a --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt @@ -0,0 +1,94 @@ +package com.tangem.features.txhistory.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowContentLead +import com.tangem.core.ui.ds2.row.TangemRowText +import com.tangem.core.ui.ds2.row.TangemRowTextRole +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM + +/** + * Info-rows block of the transaction details card: a vertical list of DS3 [TangemRow]s (label on the leading side, + * value on the trailing side — e.g. `Network fee`, `Rate`). + * + * Divider handling matches the design: a single row renders without a divider, while a multi-row block draws an inset + * bottom divider under every row except the last. The same block therefore serves both the single-asset card (one + * `Network fee` row) and the two-asset / exchange card (`Network fee` + `Rate` + …). + * + * The value is rendered in `text/secondary` to match the design — [TangemRowText]'s `Value` role is primary-colored, so + * the trailing slot uses a plain [Text] tuned to body/medium + secondary instead. + * + * @param rows Rows to render in order. An empty list renders nothing — callers should skip the block when empty. + * @param modifier Modifier applied to the list container. + */ +@Composable +internal fun TxHistoryDetailsInfoRows(rows: List, modifier: Modifier = Modifier) { + if (rows.isEmpty()) return + Column( + modifier = modifier, + ) { + val lastIndex = rows.lastIndex + rows.forEachIndexed { index, row -> + TangemRow( + divider = index < lastIndex, + contentLead = TangemRowContentLead.Start, + titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) }, + valueSlot = { + Text( + text = row.value.resolveReference(), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } +} + +// region Preview + +@Preview(name = "Light", showBackground = true, widthDp = 360) +@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360) +@Composable +private fun TxHistoryDetailsInfoRowsPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier.background(TangemTheme.colors3.bg.primary).padding(16.dp), + ) { + // Multiple rows — dividers between rows, none after the last + TxHistoryDetailsInfoRows( + rows = listOf( + InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), + InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")), + InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), + ), + ) + // Single row — no divider + TxHistoryDetailsInfoRows( + modifier = Modifier.padding(top = 16.dp), + rows = listOf( + InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), + ), + ) + } + } +} + +// endregion \ No newline at end of file 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 new file mode 100644 index 0000000000..62309f8e01 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt @@ -0,0 +1,87 @@ +package com.tangem.features.txhistory.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM + +/** + * The transaction details bottom sheet ("Operation"): the [TangemModalBottomSheet] shell shared by all transaction + * types, with the [TxHistoryDetailsTopNavigation] header in the title slot and [TxHistoryDetailsContent] (single-asset + * or two-asset body) as the content. + * + * Extracted from `DefaultTxHistoryDetailsComponent` so the whole sheet — header + body — is previewable in isolation. + * + * @param state Sheet state. `null` keeps the sheet hidden (the modal renders its empty placeholder). + * @param onDismiss Invoked on close / dismiss request. + */ +@Composable +internal fun TxHistoryDetailsModalBottomSheetContent(state: TxHistoryDetailsUM?, onDismiss: () -> Unit) { + TangemModalBottomSheet( + containerColor = TangemTheme.colors3.bg.secondary, + config = TangemBottomSheetConfig( + isShown = state != null, + onDismissRequest = onDismiss, + content = state ?: TangemBottomSheetConfigContent.Empty, + ), + title = { + state?.let { um -> TxHistoryDetailsTopNavigation(header = um.header, onCloseClick = onDismiss) } + }, + content = { um -> TxHistoryDetailsContent(state = um) }, + ) +} + +// region Preview + +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun TxHistoryDetailsModalBottomSheetContentPreview() { + TangemThemePreviewRedesign { + TxHistoryDetailsModalBottomSheetContent(state = previewSingleAsset(), onDismiss = {}) + } +} + +/** Fully-populated single-asset state exercising every sub-view: header, amount block, counterparty and info rows. */ +private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset( + header = TxHistoryDetailsUM.HeaderUM( + iconRes = R.drawable.ic_arrow_up_24, + status = Status.Confirmed, + title = stringReference("Sent"), + subtitle = stringReference("Jan 20 2026, 9:24 PM"), + ), + amountBlock = TxHistoryDetailsUM.AmountBlockUM( + currencyIcon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_eth_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + amount = stringReference("- 350.31 USDT"), + fiatAmount = stringReference("$350.31"), + isFailed = false, + ), + counterparty = TxHistoryDetailsUM.CounterpartyUM( + label = stringReference("Recipient"), + title = stringReference("33Bd321fS...ga21412B"), + avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address( + rawAddress = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359", + ), + onCopyClick = {}, + ), + rows = listOf( + TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), + ), +) + +// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt new file mode 100644 index 0000000000..f57cc7e849 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt @@ -0,0 +1,171 @@ +package com.tangem.features.txhistory.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_dots_horizontal_20 +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM + +/** + * Shared top navigation ("Nav bar") for the transaction details bottom sheet, common to all transaction types. + * + * Built on the redesigned [TangemTopNavigation]: a leading status-tinted action icon ([StatusActionIcon]) in the start + * slot, a status-colored [title][TxHistoryDetailsUM.HeaderUM.title] over a date subtitle in the center slot, and the + * trailing context-menu (`•••`, grouped in a Material pill) + close (`✕`) buttons in the end slots. + * + * Three visual states are driven by [TxHistoryDetailsUM.HeaderUM.status]: the action-icon circle background, the icon + * tint and the title color change between in-progress (brand/blue), confirmed (neutral) and failed (red). The icon + * glyph itself is kept as-is on failure — only recolored. + * + * Hosted inside a modal bottom sheet, so [WindowInsets] is zeroed (no status-bar reservation) and the background blur + * is disabled. + */ +@Composable +internal fun TxHistoryDetailsTopNavigation( + header: TxHistoryDetailsUM.HeaderUM, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TangemTopNavigation( + modifier = modifier.padding(top = 8.dp), + windowInsets = WindowInsets(0), + blurBackground = false, + startButton = { StatusActionIcon(iconRes = header.iconRes, status = header.status) }, + endButtonsGroup = { + // Context menu. Click handling is intentionally not wired yet. + TangemButton( + variant = TangemButton.Variant.Ghost, + iconStart = TangemIconUM.Icon(Icons.ic_dots_horizontal_20), + contentDescription = resourceReference(R.string.common_more).resolveReference(), + onClick = {}, + ) + }, + endButton = { TangemButton.Close(onClick = onCloseClick) }, + contentColumn = { + Text( + text = header.title.resolveReference(), + color = header.status.titleColor, + style = TangemTheme.typography3.body.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = header.subtitle.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + }, + ) +} + +@Composable +private fun StatusActionIcon(iconRes: Int, status: Status, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(44.dp) + .clip(CircleShape) + .background(status.circleBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = status.iconTint, + modifier = Modifier.size(20.dp), + ) + } +} + +// region Status -> colors3 tokens (three states) + +private val Status.circleBackground: Color + @Composable get() = when (this) { + is Status.Confirmed -> TangemTheme.colors3.bg.tertiary + is Status.Unconfirmed -> TangemTheme.colors3.bg.status.infoSubtle + is Status.Failed -> TangemTheme.colors3.bg.status.errorSubtle + } + +private val Status.iconTint: Color + @Composable get() = when (this) { + is Status.Confirmed -> TangemTheme.colors3.icon.primary + is Status.Unconfirmed -> TangemTheme.colors3.icon.accent.blue + is Status.Failed -> TangemTheme.colors3.icon.accent.red + } + +private val Status.titleColor: Color + @Composable get() = when (this) { + is Status.Confirmed -> TangemTheme.colors3.text.primary + is Status.Unconfirmed -> TangemTheme.colors3.text.brand + is Status.Failed -> TangemTheme.colors3.text.status.error + } + +// endregion + +// region Preview + +@Preview(name = "Light", showBackground = true, widthDp = 360) +@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360) +@Composable +private fun TxHistoryDetailsTopNavigationPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors3.bg.primary), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TxHistoryDetailsTopNavigation( + header = previewHeader(Status.Unconfirmed, stringReference("Swapping")), + onCloseClick = {}, + ) + TxHistoryDetailsTopNavigation( + header = previewHeader(Status.Confirmed, stringReference("Swapped")), + onCloseClick = {}, + ) + TxHistoryDetailsTopNavigation( + header = previewHeader(Status.Failed, stringReference("Swapping failed")), + onCloseClick = {}, + ) + } + } +} + +private fun previewHeader(status: Status, title: TextReference) = TxHistoryDetailsUM.HeaderUM( + iconRes = R.drawable.ic_exchange_vertical_24, + status = status, + title = title, + subtitle = stringReference("Jan 20 2026, 9:24 PM"), +) + +// endregion \ 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 d005da5c94..7d7eb52f06 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 @@ -1,9 +1,20 @@ package com.tangem.features.txhistory.converter +import android.text.format.DateFormat import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import com.tangem.features.txhistory.impl.R +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import java.math.BigDecimal @@ -11,7 +22,25 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class TxInfoToTxHistoryDetailsUMConverterTest { - private val converter = TxInfoToTxHistoryDetailsUMConverter() + private val currency = MockCryptoCurrencyFactory().ethereum + private val copiedAddresses = mutableListOf() + private val converter = TxInfoToTxHistoryDetailsUMConverter( + currency = currency, + onCopyAddress = copiedAddresses::add, + ) + + @BeforeEach + fun setUp() { + // The header subtitle formats the date via DateTimeFormatters -> DateFormat.getBestDateTimePattern, + // which is an Android stub on the JVM. Mirror the DateTimeFormattersTest mock so convert() runs. + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + } + + @AfterEach + fun tearDown() { + unmockkStatic(DateFormat::class) + } @Test fun `GIVEN Swap WHEN convert THEN TwoAssets`() { @@ -48,30 +77,170 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { } @Test - fun `GIVEN tx WHEN convert THEN title is the transaction type`() { + fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() { // Arrange - val type = TransactionType.Transfer - val tx = txInfo(type = type) + val tx = txInfo(type = TransactionType.Transfer) // Act - val result = converter.convert(tx) + val header = converter.convert(tx).header // Assert - assertThat(result.title).isEqualTo(type.toString()) + assertThat(header.iconRes).isEqualTo(R.drawable.ic_arrow_down_24) + assertThat(header.status).isEqualTo(TransactionItemUM.Content.Status.Confirmed) + assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred)) } - private fun txInfo(type: TransactionType): TxInfo = TxInfo( + @Test + fun `GIVEN Swap WHEN convert THEN header has exchange icon`() { + // Arrange + val tx = txInfo(type = TransactionType.Swap) + + // Act + val header = converter.convert(tx).header + + // Assert + assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24) + } + + @Test + fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() { + // Arrange + val tx = txInfo(type = TransactionType.Transfer, isOutgoing = false) + + // Act + val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock + + // Assert + assertThat(amountBlock.amount.resolveString()).startsWith("+ ") + assertThat(amountBlock.isFailed).isFalse() + } + + @Test + fun `GIVEN outgoing Transfer WHEN convert THEN amount block has minus sign`() { + // Arrange + val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true) + + // Act + val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock + + // Assert + assertThat(amountBlock.amount.resolveString()).startsWith("- ") + } + + @Test + fun `GIVEN zero amount WHEN convert THEN amount block has no sign`() { + // Arrange + val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO) + + // Act + val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock + + // Assert + val amount = amountBlock.amount.resolveString() + assertThat(amount).doesNotContain("+") + assertThat(amount).doesNotContain("-") + } + + @Test + fun `GIVEN failed outgoing Transfer WHEN convert THEN amount block is failed and drops the sign`() { + // Arrange + val tx = txInfo( + type = TransactionType.Transfer, + isOutgoing = true, + status = TxInfo.TransactionStatus.Failed, + ) + + // Act + val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock + + // Assert + assertThat(amountBlock.isFailed).isTrue() + val amount = amountBlock.amount.resolveString() + assertThat(amount).doesNotContain("+") + assertThat(amount).doesNotContain("-") + } + + @Test + fun `GIVEN no interaction address WHEN convert THEN counterparty is null`() { + // Arrange + val tx = txInfo(type = TransactionType.Transfer, interactionAddressType = null) + + // Act + val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty + + // Assert + assertThat(counterparty).isNull() + } + + @Test + fun `GIVEN incoming Transfer with User address WHEN convert THEN address-avatar counterparty with From label`() { + // Arrange + val tx = txInfo( + type = TransactionType.Transfer, + isOutgoing = false, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + + // Act + val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty + + // Assert + assertThat(counterparty?.avatar).isEqualTo(TxHistoryDetailsUM.CounterpartyAvatar.Address(USER_ADDRESS)) + assertThat(counterparty?.label).isEqualTo(resourceReference(R.string.common_from)) + } + + @Test + fun `GIVEN outgoing Transfer with User address WHEN convert THEN counterparty has Recipient label`() { + // Arrange + val tx = txInfo( + type = TransactionType.Transfer, + isOutgoing = true, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + + // Act + val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty + + // Assert + assertThat(counterparty?.label).isEqualTo(resourceReference(R.string.send_recipient)) + } + + @Test + fun `GIVEN address counterparty WHEN onCopyClick invoked THEN raw address is copied`() { + // Arrange + val tx = txInfo( + type = TransactionType.Transfer, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty + + // Act + counterparty?.onCopyClick?.invoke() + + // Assert + assertThat(copiedAddresses).containsExactly(USER_ADDRESS) + } + + private fun txInfo( + type: TransactionType, + isOutgoing: Boolean = false, + status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed, + amount: BigDecimal = BigDecimal.ONE, + interactionAddressType: TxInfo.InteractionAddressType? = null, + ): TxInfo = TxInfo( txHash = TX_HASH, timestampInMillis = TIMESTAMP, - isOutgoing = false, + isOutgoing = isOutgoing, destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), - interactionAddressType = null, - status = TxInfo.TransactionStatus.Confirmed, + interactionAddressType = interactionAddressType, + status = status, type = type, - amount = BigDecimal.ONE, + amount = amount, ) + private fun TextReference.resolveString(): String = (this as TextReference.Str).value + private companion object { const val TX_HASH = "0xtxhash" const val TIMESTAMP = 1_700_000_000_000L