Updated on 2026-08-14
This commit is contained in:
parent
2b9d0d9dcb
commit
a354c649b2
26 changed files with 3369 additions and 204 deletions
|
|
@ -0,0 +1,74 @@
|
|||
package com.tangem.core.ui.components.transactions
|
||||
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
internal const val INLINE_IMAGE_PLACEHOLDER = "%image%"
|
||||
private const val INLINE_IMAGE_ID = "inline_subtitle_icon"
|
||||
|
||||
/**
|
||||
* Single-line caption with an inline icon between two text parts.
|
||||
*
|
||||
* Use a string resource of the shape `"prefix %%image%% %1\$s"` (escaped `%` so the marker
|
||||
* survives Lokalise round-trips), pre-format it via `stringResourceSafe`, and pass the result
|
||||
* here — [INLINE_IMAGE_PLACEHOLDER] is replaced with an [InlineTextContent] driven by [icon].
|
||||
*/
|
||||
@Composable
|
||||
internal fun InlineImageSubtitle(
|
||||
template: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
afterIconColor: Color = color,
|
||||
iconSize: Dp = TangemTheme.dimens2.x4,
|
||||
icon: @Composable () -> Unit,
|
||||
) {
|
||||
val parts = remember(template) {
|
||||
val split = template.split(INLINE_IMAGE_PLACEHOLDER, limit = 2)
|
||||
if (split.size == 2) split[0] to split[1] else template to ""
|
||||
}
|
||||
val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() }
|
||||
val inlineContent = remember(iconSizeSp) {
|
||||
mapOf(
|
||||
INLINE_IMAGE_ID to InlineTextContent(
|
||||
placeholder = Placeholder(
|
||||
width = iconSizeSp,
|
||||
height = iconSizeSp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
|
||||
),
|
||||
children = { icon() },
|
||||
),
|
||||
)
|
||||
}
|
||||
val annotated = remember(parts, afterIconColor) {
|
||||
buildAnnotatedString {
|
||||
append(parts.first)
|
||||
appendInlineContent(INLINE_IMAGE_ID, INLINE_IMAGE_PLACEHOLDER)
|
||||
withStyle(SpanStyle(color = afterIconColor)) {
|
||||
append(parts.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = annotated,
|
||||
inlineContent = inlineContent,
|
||||
color = color,
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -48,6 +48,10 @@ import java.util.UUID
|
|||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Legacy. Use TransactionItem for redesigned screens",
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun Transaction(state: TransactionState, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
|
||||
|
|
@ -330,6 +334,7 @@ private fun TransactionState.isGoneIf(goneCondition: TransactionState.Content.()
|
|||
return if ((this as? TransactionState.Content)?.goneCondition() == true) Visibility.Gone else Visibility.Visible
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Preview(showBackground = true, widthDp = 368)
|
||||
@Preview(showBackground = true, widthDp = 368, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,443 @@
|
|||
package com.tangem.core.ui.components.transactions
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.icons.identicon.IdentIcon
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
|
||||
import com.tangem.core.ui.ds.image.TangemDeviceIcon
|
||||
import com.tangem.core.ui.ds.row.TangemRowContainer
|
||||
import com.tangem.core.ui.ds.row.TangemRowLayoutId
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.orMaskWithStars
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
||||
@Composable
|
||||
fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
|
||||
when (state) {
|
||||
is TransactionItemUM.Content -> ContentItem(
|
||||
state = state,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TransactionItemUM.Pill -> TransactionStatusPill(
|
||||
state = state,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
modifier = modifier,
|
||||
)
|
||||
is TransactionItemUM.Loading,
|
||||
is TransactionItemUM.Locked,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
|
||||
val rowModifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors2.surface.level1)
|
||||
.clickable(onClick = state.onClick)
|
||||
|
||||
TangemRowContainer(
|
||||
modifier = rowModifier,
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = TangemTheme.dimens2.x4,
|
||||
vertical = TangemTheme.dimens2.x3,
|
||||
),
|
||||
) {
|
||||
StatusCircle(
|
||||
iconRes = state.iconRes,
|
||||
status = state.status,
|
||||
modifier = Modifier
|
||||
.layoutId(TangemRowLayoutId.HEAD)
|
||||
.padding(end = TangemTheme.dimens2.x3)
|
||||
.size(TangemTheme.dimens2.x10),
|
||||
)
|
||||
TitleText(
|
||||
title = state.title,
|
||||
status = state.status,
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
|
||||
)
|
||||
SubtitleText(
|
||||
subtitle = state.subtitle,
|
||||
status = state.status,
|
||||
modifier = Modifier
|
||||
.layoutId(TangemRowLayoutId.START_BOTTOM)
|
||||
.padding(top = TangemTheme.dimens2.x0_5),
|
||||
)
|
||||
AmountText(
|
||||
amount = state.amount,
|
||||
status = state.status,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP),
|
||||
)
|
||||
CurrencyText(
|
||||
symbol = state.currencySymbol,
|
||||
modifier = Modifier
|
||||
.layoutId(TangemRowLayoutId.END_BOTTOM)
|
||||
.padding(top = TangemTheme.dimens2.x0_5),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Status circle
|
||||
|
||||
@Composable
|
||||
private fun StatusCircle(iconRes: Int, status: Status, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = status.backgroundColor,
|
||||
shape = CircleShape,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
tint = status.iconTint,
|
||||
modifier = Modifier
|
||||
.size(TangemTheme.dimens2.x5)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val Status.backgroundColor: Color
|
||||
@Composable get() = when (this) {
|
||||
is Status.Confirmed -> TangemTheme.colors2.markers.backgroundTintedGray
|
||||
is Status.Unconfirmed -> TangemTheme.colors2.markers.backgroundTintedBlue
|
||||
is Status.Failed -> TangemTheme.colors2.markers.backgroundTintedRed
|
||||
}
|
||||
|
||||
private val Status.iconTint: Color
|
||||
@Composable get() = when (this) {
|
||||
is Status.Confirmed -> TangemTheme.colors2.fill.neutral.primary
|
||||
is Status.Unconfirmed -> TangemTheme.colors2.markers.iconBlue
|
||||
is Status.Failed -> TangemTheme.colors2.markers.iconRed
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Title / Subtitle
|
||||
|
||||
@Composable
|
||||
private fun TitleText(title: TextReference, status: Status, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = title.resolveReference(),
|
||||
color = status.titleColor,
|
||||
style = TangemTheme.typography2.bodyMedium16,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private val Status.titleColor: Color
|
||||
@Composable get() = when (this) {
|
||||
is Status.Confirmed -> TangemTheme.colors2.text.neutral.primary
|
||||
is Status.Unconfirmed -> TangemTheme.colors2.text.status.accent
|
||||
is Status.Failed -> TangemTheme.colors2.text.status.warning
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Modifier = Modifier) {
|
||||
val textStyle = TangemTheme.typography2.captionMedium12
|
||||
val tertiary = TangemTheme.colors2.text.neutral.tertiary
|
||||
val primary = TangemTheme.colors2.text.neutral.primary
|
||||
val isFailed = status is Status.Failed
|
||||
when (subtitle) {
|
||||
is ContentSubtitle.Plain -> Text(
|
||||
text = subtitle.text.resolveReference(),
|
||||
color = tertiary,
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier,
|
||||
)
|
||||
is ContentSubtitle.ExternalAddress -> InlineImageSubtitle(
|
||||
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.briefAddress),
|
||||
color = tertiary,
|
||||
modifier = modifier,
|
||||
) {
|
||||
IdentIcon(
|
||||
address = subtitle.rawAddress,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
is ContentSubtitle.OwnAccount -> InlineImageSubtitle(
|
||||
template = stringResourceSafe(
|
||||
subtitle.direction.templateResId(),
|
||||
subtitle.accountName.resolveReference(),
|
||||
),
|
||||
color = tertiary,
|
||||
afterIconColor = if (isFailed) tertiary else primary,
|
||||
modifier = modifier,
|
||||
) {
|
||||
val backgroundColor = if (isFailed) {
|
||||
TangemTheme.colors2.graphic.neutral.quaternary
|
||||
} else {
|
||||
subtitle.iconBackgroundColor
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(TangemTheme.dimens2.x1))
|
||||
.background(backgroundColor),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = subtitle.iconResId),
|
||||
contentDescription = null,
|
||||
tint = TangemTheme.colors.text.constantWhite,
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x2_5),
|
||||
)
|
||||
}
|
||||
}
|
||||
is ContentSubtitle.OwnWallet -> InlineImageSubtitle(
|
||||
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.walletName),
|
||||
color = tertiary,
|
||||
afterIconColor = primary,
|
||||
modifier = modifier,
|
||||
) {
|
||||
TangemDeviceIcon(
|
||||
state = subtitle.deviceIconUM,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentSubtitle.Direction.templateResId(): Int = when (this) {
|
||||
ContentSubtitle.Direction.TO -> R.string.transaction_history_to_inline_address
|
||||
ContentSubtitle.Direction.FROM -> R.string.transaction_history_from_inline_address
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Amount
|
||||
|
||||
@Composable
|
||||
private fun AmountText(amount: String, status: Status, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
|
||||
val display = if (status is Status.Failed) amount.stripLeadingSign() else amount
|
||||
Text(
|
||||
text = display.orMaskWithStars(isBalanceHidden),
|
||||
color = if (status is Status.Confirmed) {
|
||||
TangemTheme.colors2.text.neutral.primary
|
||||
} else {
|
||||
TangemTheme.colors2.text.neutral.tertiary
|
||||
},
|
||||
textDecoration = if (status is Status.Failed) TextDecoration.LineThrough else null,
|
||||
style = TangemTheme.typography2.bodyMedium16,
|
||||
maxLines = 1,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrencyText(symbol: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = symbol,
|
||||
color = TangemTheme.colors2.text.neutral.tertiary,
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
maxLines = 1,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.stripLeadingSign(): String = when {
|
||||
startsWith('+') || startsWith('-') || startsWith('−') -> drop(1).trim()
|
||||
else -> this
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Preview
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun previewContent(
|
||||
txHash: String,
|
||||
iconRes: Int,
|
||||
direction: Direction,
|
||||
status: Status,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
amount: String,
|
||||
currencySymbol: String = "USDT",
|
||||
): TransactionItemUM.Content = TransactionItemUM.Content(
|
||||
txHash = txHash,
|
||||
amount = amount,
|
||||
currencySymbol = currencySymbol,
|
||||
time = "",
|
||||
status = status,
|
||||
direction = direction,
|
||||
onClick = {},
|
||||
iconRes = iconRes,
|
||||
title = stringReference(title),
|
||||
subtitle = ContentSubtitle.Plain(stringReference(subtitle)),
|
||||
timestamp = 0L,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun PreviewColumn(items: List<TransactionItemUM>) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors2.surface.level1)
|
||||
.padding(TangemTheme.dimens2.x2),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
items.forEach { TransactionItem(state = it, isBalanceHidden = false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_TransactionItem_Receive() {
|
||||
TangemThemePreviewRedesign {
|
||||
PreviewColumn(
|
||||
items = listOf(
|
||||
previewContent(
|
||||
txHash = "rcv-c",
|
||||
iconRes = R.drawable.ic_arrow_down_24,
|
||||
direction = Direction.INCOMING,
|
||||
status = Status.Confirmed,
|
||||
title = "Received",
|
||||
subtitle = "from: 33BdfS...ga2B",
|
||||
amount = "+350.00",
|
||||
),
|
||||
previewContent(
|
||||
txHash = "rcv-u",
|
||||
iconRes = R.drawable.ic_arrow_down_24,
|
||||
direction = Direction.INCOMING,
|
||||
status = Status.Unconfirmed,
|
||||
title = "Receiving",
|
||||
subtitle = "from: 33BdfS...ga2B",
|
||||
amount = "+350.00",
|
||||
),
|
||||
previewContent(
|
||||
txHash = "rcv-f",
|
||||
iconRes = R.drawable.ic_close_24,
|
||||
direction = Direction.INCOMING,
|
||||
status = Status.Failed,
|
||||
title = "Receiving failed",
|
||||
subtitle = "from: 33BdfS...ga2B",
|
||||
amount = "350.00",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_TransactionItem_Send() {
|
||||
TangemThemePreviewRedesign {
|
||||
PreviewColumn(
|
||||
items = listOf(
|
||||
previewContent(
|
||||
txHash = "snd-c",
|
||||
iconRes = R.drawable.ic_arrow_up_24,
|
||||
direction = Direction.OUTGOING,
|
||||
status = Status.Confirmed,
|
||||
title = "Sent",
|
||||
subtitle = "to: 33BdfS...ga2B",
|
||||
amount = "-350.31",
|
||||
),
|
||||
previewContent(
|
||||
txHash = "snd-u",
|
||||
iconRes = R.drawable.ic_arrow_up_24,
|
||||
direction = Direction.OUTGOING,
|
||||
status = Status.Unconfirmed,
|
||||
title = "Sending",
|
||||
subtitle = "to: 33BdfS...ga2B",
|
||||
amount = "+350.31",
|
||||
),
|
||||
previewContent(
|
||||
txHash = "snd-f",
|
||||
iconRes = R.drawable.ic_close_24,
|
||||
direction = Direction.OUTGOING,
|
||||
status = Status.Failed,
|
||||
title = "Sending failed",
|
||||
subtitle = "to: 33BdfS...ga2B",
|
||||
amount = "350.31",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_TransactionItem_Swap() {
|
||||
TangemThemePreviewRedesign {
|
||||
PreviewColumn(
|
||||
items = listOf(
|
||||
previewContent(
|
||||
txHash = "swp-c",
|
||||
iconRes = R.drawable.ic_exchange_vertical_24,
|
||||
direction = Direction.INCOMING,
|
||||
status = Status.Confirmed,
|
||||
title = "Swapped",
|
||||
subtitle = "to: POL",
|
||||
amount = "+350.00",
|
||||
),
|
||||
previewContent(
|
||||
txHash = "swp-u",
|
||||
iconRes = R.drawable.ic_exchange_vertical_24,
|
||||
direction = Direction.INCOMING,
|
||||
status = Status.Unconfirmed,
|
||||
title = "Swapping",
|
||||
subtitle = "to: POL",
|
||||
amount = "+350.00",
|
||||
),
|
||||
previewContent(
|
||||
txHash = "swp-f",
|
||||
iconRes = R.drawable.ic_close_24,
|
||||
direction = Direction.INCOMING,
|
||||
status = Status.Failed,
|
||||
title = "Swapping failed",
|
||||
subtitle = "to: POL",
|
||||
amount = "350.00",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
package com.tangem.core.ui.components.transactions
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.unit.dp
|
||||
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 com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.icons.identicon.IdentIcon
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.PillKind
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.PillSubtitle
|
||||
import com.tangem.core.ui.extensions.orMaskWithStars
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
||||
@Composable
|
||||
internal fun TransactionStatusPill(
|
||||
state: TransactionItemUM.Pill,
|
||||
isBalanceHidden: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(TangemTheme.colors2.surface.level1)
|
||||
.clickable(onClick = state.onClick)
|
||||
.padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Pill(state = state, isBalanceHidden = isBalanceHidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Pill(state: TransactionItemUM.Pill, isBalanceHidden: Boolean) {
|
||||
val labelColor = state.status.labelColor()
|
||||
val secondaryColor = state.status.secondaryColor()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(TangemTheme.colors2.tabs.backgroundSecondary)
|
||||
.padding(horizontal = TangemTheme.dimens2.x2, vertical = TangemTheme.dimens2.x1),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
|
||||
) {
|
||||
LeadingIcon(kind = state.kind, status = state.status)
|
||||
if (state.status is Status.Failed && state.amount != null) {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.common_action_failed, state.failedBody(isBalanceHidden)),
|
||||
color = labelColor,
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = state.label.resolveReference(),
|
||||
color = labelColor,
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
)
|
||||
if (state.amount != null) {
|
||||
Text(
|
||||
text = state.amount.orMaskWithStars(isBalanceHidden),
|
||||
color = secondaryColor,
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
)
|
||||
state.currencySymbol?.let { symbol ->
|
||||
Text(
|
||||
text = symbol,
|
||||
color = secondaryColor,
|
||||
style = TangemTheme.typography2.captionMedium12,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val subtitle = state.subtitle
|
||||
if (subtitle is PillSubtitle.Address && state.status !is Status.Failed) {
|
||||
InlineImageSubtitle(
|
||||
template = stringResourceSafe(
|
||||
R.string.transaction_history_to_inline_address,
|
||||
subtitle.briefAddress,
|
||||
),
|
||||
color = secondaryColor,
|
||||
afterIconColor = labelColor,
|
||||
) {
|
||||
IdentIcon(
|
||||
address = subtitle.rawAddress,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeadingIcon(kind: PillKind, status: Status) {
|
||||
if (status is Status.Unconfirmed) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 1.5.dp,
|
||||
color = TangemTheme.colors2.markers.iconBlue,
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x4),
|
||||
)
|
||||
return
|
||||
}
|
||||
val iconRes = when (status) {
|
||||
is Status.Failed -> R.drawable.ic_close_24
|
||||
is Status.Confirmed -> when (kind) {
|
||||
PillKind.STAKING -> R.drawable.ic_transaction_history_staking_24
|
||||
PillKind.YIELD_MODE -> R.drawable.ic_yield_mode_16
|
||||
PillKind.APPROVE -> null
|
||||
}
|
||||
is Status.Unconfirmed -> null
|
||||
} ?: return
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
tint = status.iconTint(),
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x4),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Status.labelColor(): Color = when (this) {
|
||||
is Status.Confirmed -> TangemTheme.colors2.text.neutral.secondary
|
||||
is Status.Unconfirmed -> TangemTheme.colors2.text.status.accent
|
||||
is Status.Failed -> TangemTheme.colors2.text.status.warning
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Status.secondaryColor(): Color = when (this) {
|
||||
is Status.Confirmed -> TangemTheme.colors2.text.neutral.primary
|
||||
is Status.Unconfirmed -> TangemTheme.colors2.text.status.accent
|
||||
is Status.Failed -> TangemTheme.colors2.text.status.warning
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Status.iconTint(): Color = when (this) {
|
||||
is Status.Confirmed -> TangemTheme.colors2.fill.neutral.primary
|
||||
is Status.Unconfirmed -> TangemTheme.colors2.markers.iconBlue
|
||||
is Status.Failed -> TangemTheme.colors2.markers.iconRed
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionItemUM.Pill.failedBody(isBalanceHidden: Boolean): String = buildString {
|
||||
append(label.resolveReference())
|
||||
amount?.let { value ->
|
||||
append(' ')
|
||||
append(value.orMaskWithStars(isBalanceHidden))
|
||||
}
|
||||
currencySymbol?.let { symbol ->
|
||||
append(' ')
|
||||
append(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
|
||||
private fun previewPill(
|
||||
txHash: String,
|
||||
kind: PillKind,
|
||||
status: Status,
|
||||
label: String,
|
||||
amount: String? = null,
|
||||
currencySymbol: String? = null,
|
||||
subtitle: PillSubtitle? = null,
|
||||
): TransactionItemUM.Pill = TransactionItemUM.Pill(
|
||||
txHash = txHash,
|
||||
kind = kind,
|
||||
status = status,
|
||||
label = stringReference(label),
|
||||
amount = amount,
|
||||
currencySymbol = currencySymbol,
|
||||
subtitle = subtitle,
|
||||
timestamp = 0L,
|
||||
onClick = {},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun PillPreviewColumn(items: List<TransactionItemUM.Pill>) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(TangemTheme.colors2.surface.level1)
|
||||
.padding(vertical = TangemTheme.dimens2.x2),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
|
||||
) {
|
||||
items.forEach { TransactionStatusPill(state = it, isBalanceHidden = false) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NamedArguments")
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_TransactionStatusPill_Staking() {
|
||||
TangemThemePreviewRedesign {
|
||||
PillPreviewColumn(
|
||||
items = listOf(
|
||||
previewPill("stk-c", PillKind.STAKING, Status.Confirmed, "Staked", "950.43", "TRX"),
|
||||
previewPill("stk-u", PillKind.STAKING, Status.Unconfirmed, "Staking", "1,000.00", "TRX"),
|
||||
previewPill("stk-f", PillKind.STAKING, Status.Failed, "Staking failed"),
|
||||
previewPill("ust-c", PillKind.STAKING, Status.Confirmed, "Unstaked", "950.43", "TRX"),
|
||||
previewPill("ust-u", PillKind.STAKING, Status.Unconfirmed, "Unstaking", "1,000.00", "TRX"),
|
||||
previewPill("ust-f", PillKind.STAKING, Status.Failed, "Unstaking failed"),
|
||||
previewPill("rst-c", PillKind.STAKING, Status.Confirmed, "Rewards restaked", "20.15", "TRX"),
|
||||
previewPill("rst-u", PillKind.STAKING, Status.Unconfirmed, "Rewards restaking", "20.15", "TRX"),
|
||||
previewPill("rst-f", PillKind.STAKING, Status.Failed, "Rewards restaking failed"),
|
||||
previewPill("wd-c", PillKind.STAKING, Status.Confirmed, "Withdraw"),
|
||||
previewPill("wd-u", PillKind.STAKING, Status.Unconfirmed, "Withdrawing"),
|
||||
previewPill("wd-f", PillKind.STAKING, Status.Failed, "Withdraw failed"),
|
||||
previewPill("vt-c", PillKind.STAKING, Status.Confirmed, "Vote"),
|
||||
previewPill("vt-u", PillKind.STAKING, Status.Unconfirmed, "Voting"),
|
||||
previewPill("vt-f", PillKind.STAKING, Status.Failed, "Vote failed"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NamedArguments")
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_TransactionStatusPill_YieldMode() {
|
||||
TangemThemePreviewRedesign {
|
||||
PillPreviewColumn(
|
||||
items = listOf(
|
||||
previewPill("yon-c", PillKind.YIELD_MODE, Status.Confirmed, "Yield mode Enabled"),
|
||||
previewPill("yon-u", PillKind.YIELD_MODE, Status.Unconfirmed, "Activating Yield mode"),
|
||||
previewPill("yon-f", PillKind.YIELD_MODE, Status.Failed, "Yield mode failed"),
|
||||
previewPill("yof-c", PillKind.YIELD_MODE, Status.Confirmed, "Yield mode disabled"),
|
||||
previewPill("yof-u", PillKind.YIELD_MODE, Status.Unconfirmed, "Disabling Yield mode"),
|
||||
previewPill("yof-f", PillKind.YIELD_MODE, Status.Failed, "Disabling Yield mode failed"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NamedArguments")
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_TransactionStatusPill_Approve() {
|
||||
TangemThemePreviewRedesign {
|
||||
PillPreviewColumn(
|
||||
items = listOf(
|
||||
// dApp variant — no subtitle
|
||||
previewPill("apv-c", PillKind.APPROVE, Status.Confirmed, "Approved", "2,350.00", "USDT"),
|
||||
previewPill("apv-u", PillKind.APPROVE, Status.Unconfirmed, "Approving", "2,350.00", "USDT"),
|
||||
previewPill("apv-f", PillKind.APPROVE, Status.Failed, "Approving", "2,350.00", "USDT"),
|
||||
// Address variant — with subtitle
|
||||
previewPill(
|
||||
txHash = "apa-c",
|
||||
kind = PillKind.APPROVE,
|
||||
status = Status.Confirmed,
|
||||
label = "Approved",
|
||||
amount = "2,350.00",
|
||||
currencySymbol = "USDT",
|
||||
subtitle = PillSubtitle.Address(
|
||||
rawAddress = "33BdfSXXXXXXXXXXXXXXXXXXXXXXga2B",
|
||||
briefAddress = "33BdfS...ga2B",
|
||||
),
|
||||
),
|
||||
previewPill(
|
||||
txHash = "apa-u",
|
||||
kind = PillKind.APPROVE,
|
||||
status = Status.Unconfirmed,
|
||||
label = "Approving",
|
||||
amount = "2,350.00",
|
||||
currencySymbol = "USDT",
|
||||
subtitle = PillSubtitle.Address(
|
||||
rawAddress = "33BdfSXXXXXXXXXXXXXXXXXXXXXXga2B",
|
||||
briefAddress = "33BdfS...ga2B",
|
||||
),
|
||||
),
|
||||
previewPill(
|
||||
txHash = "apa-f",
|
||||
kind = PillKind.APPROVE,
|
||||
status = Status.Failed,
|
||||
label = "Approving",
|
||||
amount = "2,350.00",
|
||||
currencySymbol = "USDT",
|
||||
subtitle = PillSubtitle.Address(
|
||||
rawAddress = "33BdfSXXXXXXXXXXXXXXXXXXXXXXga2B",
|
||||
briefAddress = "33BdfS...ga2B",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.core.ui.components.transactions
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.tooling.preview.Preview
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
||||
@Composable
|
||||
fun TxHistoryDateHeader(title: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
style = TangemTheme.typography2.bodyMedium16,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = TangemTheme.dimens2.x4,
|
||||
end = TangemTheme.dimens2.x4,
|
||||
top = TangemTheme.dimens2.x6,
|
||||
bottom = TangemTheme.dimens2.x3,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun Preview_TxHistoryDateHeader() {
|
||||
TangemThemePreviewRedesign {
|
||||
TxHistoryDateHeader(title = "Today")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package com.tangem.core.ui.components.transactions.state
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.tangem.core.ui.ds.image.DeviceIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
|
||||
/**
|
||||
* UI model for the redesigned transaction list item ([REDACTED_TASK_KEY]).
|
||||
*
|
||||
* Mirrors the field set of the legacy [TransactionState] but splits the formatted amount string
|
||||
* into a numeric [Content.amount] (with sign) and a separate [Content.currencySymbol], so the
|
||||
* redesigned `TransactionItem` composable can render them on independent lines without parsing.
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface TransactionItemUM {
|
||||
|
||||
/** Transaction hash */
|
||||
val txHash: String
|
||||
|
||||
/**
|
||||
* Content state.
|
||||
*
|
||||
* @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded
|
||||
* @property currencySymbol currency symbol shown alongside [amount], e.g. "BTC", "USDT"
|
||||
*/
|
||||
data class Content(
|
||||
override val txHash: String,
|
||||
val amount: String,
|
||||
val currencySymbol: String,
|
||||
val time: String,
|
||||
val status: Status,
|
||||
val direction: Direction,
|
||||
val onClick: () -> Unit,
|
||||
@DrawableRes val iconRes: Int,
|
||||
val title: TextReference,
|
||||
val subtitle: ContentSubtitle,
|
||||
val timestamp: Long,
|
||||
) : TransactionItemUM {
|
||||
|
||||
@Immutable
|
||||
sealed class Status {
|
||||
data object Failed : Status()
|
||||
data object Confirmed : Status()
|
||||
data object Unconfirmed : Status()
|
||||
}
|
||||
|
||||
enum class Direction {
|
||||
INCOMING,
|
||||
OUTGOING,
|
||||
}
|
||||
}
|
||||
|
||||
/** Subtitle variants for [Content] rows. */
|
||||
@Immutable
|
||||
sealed interface ContentSubtitle {
|
||||
/** Plain text — for types without a directly-displayable address (Operation, GaslessFee, ClaimRewards, etc.). */
|
||||
data class Plain(val text: TextReference) : ContentSubtitle
|
||||
|
||||
/**
|
||||
* External counterparty address — renders as "to/from: <identicon> <briefAddress>".
|
||||
* Used for Transfer to/from external addresses.
|
||||
*/
|
||||
data class ExternalAddress(
|
||||
val direction: Direction,
|
||||
val rawAddress: String,
|
||||
val briefAddress: String,
|
||||
) : ContentSubtitle
|
||||
|
||||
/**
|
||||
* Counterparty matches one of the user's own accounts — renders as "to/from: <accountIcon> <accountName>".
|
||||
*/
|
||||
data class OwnAccount(
|
||||
val direction: Direction,
|
||||
val accountName: TextReference,
|
||||
@DrawableRes val iconResId: Int,
|
||||
val iconBackgroundColor: Color,
|
||||
) : ContentSubtitle
|
||||
|
||||
/**
|
||||
* Counterparty matches one of the user's own wallets (cross-wallet transfer with accounts mode disabled) —
|
||||
* renders as "to/from: <walletIcon> <walletName>".
|
||||
*/
|
||||
data class OwnWallet(
|
||||
val direction: Direction,
|
||||
val walletName: String,
|
||||
val deviceIconUM: DeviceIconUM,
|
||||
) : ContentSubtitle
|
||||
|
||||
enum class Direction { TO, FROM }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact status pill — used for Staking / YieldMode / Approve transactions where the row format
|
||||
* is replaced by a single chip with status-aware colors.
|
||||
*
|
||||
* @property kind controls leading icon and color tint
|
||||
* @property status drives background/text colors and Failed/Unconfirmed icon override
|
||||
* @property label full pill label text (already composed by converter, e.g. "Staked")
|
||||
* @property amount optional signed numeric value rendered after [label] (e.g. "950.43");
|
||||
* null for kinds that don't carry amount (Vote, Withdraw, Yield mode)
|
||||
* @property currencySymbol currency symbol rendered after [amount]; null when [amount] is null
|
||||
* @property subtitle optional subtitle (e.g. "to: 33Bd...ga2B" with avatar) for Approve
|
||||
*/
|
||||
data class Pill(
|
||||
override val txHash: String,
|
||||
val kind: PillKind,
|
||||
val status: Content.Status,
|
||||
val label: TextReference,
|
||||
val amount: String?,
|
||||
val currencySymbol: String?,
|
||||
val subtitle: PillSubtitle?,
|
||||
val timestamp: Long,
|
||||
val onClick: () -> Unit,
|
||||
) : TransactionItemUM
|
||||
|
||||
enum class PillKind {
|
||||
STAKING,
|
||||
YIELD_MODE,
|
||||
APPROVE,
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface PillSubtitle {
|
||||
/** Address subtitle with inline IdentIcon (Blockies 8×8, hashed from [rawAddress]). */
|
||||
data class Address(val rawAddress: String, val briefAddress: String) : PillSubtitle
|
||||
}
|
||||
|
||||
data class Loading(override val txHash: String) : TransactionItemUM
|
||||
|
||||
data class Locked(override val txHash: String) : TransactionItemUM
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue