Updated on 2026-08-14
This commit is contained in:
parent
b7c264b448
commit
6e92e35a38
13 changed files with 1060 additions and 49 deletions
|
|
@ -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"
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<TxHistoryDetailsUM>(
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<TxInfo, TxHistoryDetailsUM> {
|
||||
internal class TxInfoToTxHistoryDetailsUMConverter(
|
||||
private val currency: CryptoCurrency,
|
||||
private val onCopyAddress: (String) -> Unit,
|
||||
) : Converter<TxInfo, TxHistoryDetailsUM> {
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -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<InfoRowUM>,
|
||||
) : 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<TxHistoryDetailsUM?> = 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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<InfoRowUM>, 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
|
||||
|
|
@ -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<TxHistoryDetailsUM>(
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
@ -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<String>()
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue