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
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T
|
|||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight
|
||||
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
||||
import com.tangem.features.txhistory.component.TxHistoryComponent
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyComponent
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
|
|
@ -91,6 +92,9 @@ internal fun TokenDetailsScreen(
|
|||
|
||||
val rootBackground by LocalRootBackgroundColor.current
|
||||
var marketBlockHeight by remember { mutableStateOf(0.dp) }
|
||||
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
|
||||
val fadeFloorHeight = TangemTheme.dimens.size100 + bottomBarHeight
|
||||
val effectiveBottomPadding = maxOf(partialCollapsedHeight + marketBlockHeight, fadeFloorHeight)
|
||||
val notificationModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = TangemTheme.dimens2.x4)
|
||||
|
|
@ -121,7 +125,7 @@ internal fun TokenDetailsScreen(
|
|||
yieldSupplyComponent = yieldSupplyComponent,
|
||||
txHistoryComponent = txHistoryComponent,
|
||||
rootBackground = rootBackground,
|
||||
bottomContentPadding = marketBlockHeight,
|
||||
bottomContentPadding = effectiveBottomPadding,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(behavior.nestedScrollConnection),
|
||||
|
|
@ -291,13 +295,17 @@ private fun TokenDetailsScreen_Preview() {
|
|||
override fun Content(modifier: Modifier) = Unit
|
||||
},
|
||||
txHistoryComponent = object : TxHistoryComponent {
|
||||
override val txHistoryState: StateFlow<TxHistoryUM> = MutableStateFlow(
|
||||
override val legacyTxHistoryState: StateFlow<TxHistoryUM> = MutableStateFlow(
|
||||
value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}),
|
||||
)
|
||||
|
||||
override val txHistoryState: StateFlow<TxHistoryItemsUM> = MutableStateFlow(
|
||||
value = TxHistoryItemsUM.Empty(isBalanceHidden = false, onExploreClick = {}),
|
||||
)
|
||||
|
||||
override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) = Unit
|
||||
|
||||
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) = Unit
|
||||
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) = Unit
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.e
|
|||
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy
|
||||
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
|
||||
import com.tangem.features.txhistory.component.TxHistoryComponent
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyComponent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -56,7 +57,7 @@ internal fun TokenDetailsScreenLegacy(
|
|||
containerColor = TangemTheme.colors.background.secondary,
|
||||
) { scaffoldPaddings ->
|
||||
val listState = rememberLazyListState()
|
||||
val txHistoryComponentState by txHistoryComponent.txHistoryState.collectAsStateWithLifecycle()
|
||||
val txHistoryComponentState by txHistoryComponent.legacyTxHistoryState.collectAsStateWithLifecycle()
|
||||
val betweenItemsPadding = TangemTheme.dimens.spacing12
|
||||
val horizontalPadding = TangemTheme.dimens.spacing16
|
||||
val itemModifier = Modifier
|
||||
|
|
@ -177,13 +178,17 @@ private fun TokenDetailsScreenPreview(
|
|||
state = state,
|
||||
tokenMarketBlockComponent = null,
|
||||
txHistoryComponent = object : TxHistoryComponent {
|
||||
override val txHistoryState: StateFlow<TxHistoryUM> = MutableStateFlow(
|
||||
override val legacyTxHistoryState: StateFlow<TxHistoryUM> = MutableStateFlow(
|
||||
value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}),
|
||||
)
|
||||
|
||||
override val txHistoryState: StateFlow<TxHistoryItemsUM> = MutableStateFlow(
|
||||
value = TxHistoryItemsUM.Empty(isBalanceHidden = false, onExploreClick = {}),
|
||||
)
|
||||
|
||||
override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) = Unit
|
||||
|
||||
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) = Unit
|
||||
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) = Unit
|
||||
},
|
||||
yieldSupplyComponent = object : YieldSupplyComponent {
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ dependencies {
|
|||
/** Compose */
|
||||
implementation(deps.compose.runtime)
|
||||
implementation(deps.compose.foundation)
|
||||
implementation(deps.compose.material3)
|
||||
implementation(deps.compose.ui.tooling)
|
||||
|
||||
/** Other */
|
||||
|
|
|
|||
|
|
@ -6,17 +6,20 @@ import androidx.compose.runtime.Stable
|
|||
import com.tangem.core.decompose.factory.ComponentFactory
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Stable
|
||||
interface TxHistoryComponent {
|
||||
|
||||
val txHistoryState: StateFlow<TxHistoryUM>
|
||||
val legacyTxHistoryState: StateFlow<TxHistoryUM>
|
||||
|
||||
val txHistoryState: StateFlow<TxHistoryItemsUM>
|
||||
|
||||
fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM)
|
||||
|
||||
fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM)
|
||||
fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM)
|
||||
|
||||
data class Params(
|
||||
val userWalletId: UserWalletId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
package com.tangem.features.txhistory.entity
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Transaction history state for Token Details ([REDACTED_TASK_KEY]).
|
||||
*
|
||||
* Parallel to [TxHistoryUM] but uses [TransactionItemUM] for transaction items so the
|
||||
* `TransactionItem` composable can render structured fields without parsing.
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface TxHistoryItemsUM {
|
||||
|
||||
val isBalanceHidden: Boolean
|
||||
|
||||
data class Loading(
|
||||
override val isBalanceHidden: Boolean,
|
||||
val onExploreClick: () -> Unit,
|
||||
) : TxHistoryItemsUM {
|
||||
val items = persistentListOf(
|
||||
TxHistoryItemUM.Transaction(TransactionItemUM.Loading("LOADING_TX_HASH_1")),
|
||||
TxHistoryItemUM.Transaction(TransactionItemUM.Loading("LOADING_TX_HASH_2")),
|
||||
TxHistoryItemUM.Transaction(TransactionItemUM.Loading("LOADING_TX_HASH_3")),
|
||||
TxHistoryItemUM.Transaction(TransactionItemUM.Loading("LOADING_TX_HASH_4")),
|
||||
)
|
||||
}
|
||||
|
||||
data class Content(
|
||||
override val isBalanceHidden: Boolean,
|
||||
val items: ImmutableList<TxHistoryItemUM>,
|
||||
val isLoadingMore: Boolean,
|
||||
val loadMore: () -> Boolean,
|
||||
) : TxHistoryItemsUM
|
||||
|
||||
data class Empty(override val isBalanceHidden: Boolean, val onExploreClick: () -> Unit) : TxHistoryItemsUM
|
||||
|
||||
data class NotSupported(
|
||||
override val isBalanceHidden: Boolean,
|
||||
val pendingTransactions: ImmutableList<TransactionItemUM>,
|
||||
val onExploreClick: () -> Unit,
|
||||
) : TxHistoryItemsUM
|
||||
|
||||
data class Error(
|
||||
override val isBalanceHidden: Boolean,
|
||||
val onReloadClick: () -> Unit,
|
||||
val onExploreClick: () -> Unit,
|
||||
) : TxHistoryItemsUM
|
||||
|
||||
fun copySealed(isBalanceHidden: Boolean): TxHistoryItemsUM {
|
||||
return when (this) {
|
||||
is Content -> copy(isBalanceHidden = isBalanceHidden)
|
||||
is NotSupported -> copy(isBalanceHidden = isBalanceHidden)
|
||||
is Empty -> copy(isBalanceHidden = isBalanceHidden)
|
||||
is Error -> copy(isBalanceHidden = isBalanceHidden)
|
||||
is Loading -> copy(isBalanceHidden = isBalanceHidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface TxHistoryItemUM {
|
||||
|
||||
data class GroupTitle(
|
||||
val title: String,
|
||||
val itemKey: String,
|
||||
) : TxHistoryItemUM
|
||||
|
||||
data class Transaction(val state: TransactionItemUM) : TxHistoryItemUM
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.features.txhistory.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -8,7 +9,10 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
|
|
@ -18,13 +22,19 @@ import androidx.compose.ui.util.lerp
|
|||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.CircleShimmer
|
||||
import com.tangem.core.ui.components.RectangleShimmer
|
||||
import com.tangem.core.ui.components.list.InfiniteListHandler
|
||||
import com.tangem.core.ui.components.transactions.TransactionItem
|
||||
import com.tangem.core.ui.components.transactions.TxHistoryDateHeader
|
||||
import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock
|
||||
import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
||||
import com.tangem.core.ui.ds.row.TangemRowContainer
|
||||
import com.tangem.core.ui.ds.row.TangemRowLayoutId
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM.TxHistoryItemUM
|
||||
|
||||
private val LoadingTitleShimmerWidth = 52.dp
|
||||
private val LoadingPrimaryShimmerWidth = 110.dp
|
||||
|
|
@ -33,56 +43,112 @@ private val LoadingEndTopShimmerWidth = 107.dp
|
|||
private val LoadingEndBottomShimmerWidth = 52.dp
|
||||
|
||||
private const val LOADING_TRANSACTION_MIN_ALPHA = 0.1f
|
||||
private const val LOAD_MORE_BUFFER = 20
|
||||
|
||||
fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) {
|
||||
fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryItemsUM) {
|
||||
when (state) {
|
||||
is TxHistoryUM.Content -> contentItems(listState, state)
|
||||
is TxHistoryUM.Empty -> emptyItem(state)
|
||||
is TxHistoryUM.Error -> errorItem(state)
|
||||
is TxHistoryUM.Loading -> loadingItems(state)
|
||||
is TxHistoryUM.NotSupported -> notSupportedItem(state)
|
||||
is TxHistoryItemsUM.Content -> contentItems(listState, state)
|
||||
is TxHistoryItemsUM.Empty -> emptyItem(state)
|
||||
is TxHistoryItemsUM.Error -> errorItem(state)
|
||||
is TxHistoryItemsUM.Loading -> loadingItems(state)
|
||||
is TxHistoryItemsUM.NotSupported -> notSupportedItem(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) {
|
||||
item(key = "tx_history_content", contentType = "tx_history_content") {
|
||||
TxHistoryContentBlock(state = state)
|
||||
private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryItemsUM.Content) {
|
||||
items(
|
||||
items = state.items,
|
||||
key = { item ->
|
||||
when (item) {
|
||||
is TxHistoryItemUM.GroupTitle -> "group_title:${item.itemKey}"
|
||||
is TxHistoryItemUM.Transaction -> "tx:${item.state.txHash}"
|
||||
}
|
||||
},
|
||||
contentType = { item -> item::class.java },
|
||||
) { item ->
|
||||
when (item) {
|
||||
is TxHistoryItemUM.GroupTitle -> TxHistoryDateHeader(title = item.title)
|
||||
is TxHistoryItemUM.Transaction -> TransactionItem(
|
||||
state = item.state,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "tx_history_load_more", contentType = "tx_history_load_more") {
|
||||
TxHistoryLoadMoreFooter(
|
||||
listState = listState,
|
||||
isLoadingMore = state.isLoadingMore,
|
||||
onLoadMore = state.loadMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.emptyItem(state: TxHistoryUM.Empty) {
|
||||
@Composable
|
||||
private fun TxHistoryLoadMoreFooter(
|
||||
listState: LazyListState,
|
||||
isLoadingMore: Boolean,
|
||||
onLoadMore: () -> Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
InfiniteListHandler(
|
||||
listState = listState,
|
||||
buffer = LOAD_MORE_BUFFER,
|
||||
onLoadMore = onLoadMore,
|
||||
)
|
||||
if (isLoadingMore) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = TangemTheme.dimens2.x4),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(TangemTheme.dimens2.x6),
|
||||
color = TangemTheme.colors2.graphic.neutral.tertiaryConstant,
|
||||
strokeWidth = TangemTheme.dimens2.x0_5,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.emptyItem(state: TxHistoryItemsUM.Empty) {
|
||||
item(key = "tx_history_empty", contentType = "tx_history_empty") {
|
||||
TxHistoryEmptyBlock(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.errorItem(state: TxHistoryUM.Error) {
|
||||
private fun LazyListScope.errorItem(state: TxHistoryItemsUM.Error) {
|
||||
item(key = "tx_history_error", contentType = "tx_history_error") {
|
||||
TxHistoryErrorBlock(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.loadingItems(state: TxHistoryUM.Loading) {
|
||||
private fun LazyListScope.loadingItems(state: TxHistoryItemsUM.Loading) {
|
||||
item(key = "tx_history_loading", contentType = "tx_history_loading") {
|
||||
TxHistoryLoadingBlock(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.notSupportedItem(state: TxHistoryUM.NotSupported) {
|
||||
private fun LazyListScope.notSupportedItem(state: TxHistoryItemsUM.NotSupported) {
|
||||
if (state.pendingTransactions.isNotEmpty()) {
|
||||
item(key = "tx_history_pending_header", contentType = "tx_history_pending_header") {
|
||||
TxHistoryDateHeader(title = stringResourceSafe(R.string.transaction_history_pending))
|
||||
}
|
||||
items(
|
||||
items = state.pendingTransactions,
|
||||
key = { item -> "pending_tx:${item.txHash}" },
|
||||
contentType = { TransactionItemUM::class.java },
|
||||
) { item ->
|
||||
TransactionItem(state = item, isBalanceHidden = state.isBalanceHidden)
|
||||
}
|
||||
}
|
||||
item(key = "tx_history_not_supported", contentType = "tx_history_not_supported") {
|
||||
TxHistoryNotSupportedBlock(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@Composable
|
||||
private fun TxHistoryContentBlock(state: TxHistoryUM.Content, modifier: Modifier = Modifier) {
|
||||
// TODO [REDACTED_TASK_KEY] redesign Content state
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TxHistoryEmptyBlock(state: TxHistoryUM.Empty, modifier: Modifier = Modifier) {
|
||||
private fun TxHistoryEmptyBlock(state: TxHistoryItemsUM.Empty, modifier: Modifier = Modifier) {
|
||||
EmptyTransactionBlock(
|
||||
state = EmptyTransactionsBlockState.Empty(
|
||||
onExplore = state.onExploreClick,
|
||||
|
|
@ -93,7 +159,7 @@ private fun TxHistoryEmptyBlock(state: TxHistoryUM.Empty, modifier: Modifier = M
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun TxHistoryErrorBlock(state: TxHistoryUM.Error, modifier: Modifier = Modifier) {
|
||||
private fun TxHistoryErrorBlock(state: TxHistoryItemsUM.Error, modifier: Modifier = Modifier) {
|
||||
EmptyTransactionBlock(
|
||||
state = EmptyTransactionsBlockState.FailedToLoad(
|
||||
onReload = state.onReloadClick,
|
||||
|
|
@ -106,31 +172,20 @@ private fun TxHistoryErrorBlock(state: TxHistoryUM.Error, modifier: Modifier = M
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun TxHistoryLoadingBlock(state: TxHistoryUM.Loading, modifier: Modifier = Modifier) {
|
||||
val transactionCount = state.items.count { it is TxHistoryUM.TxHistoryItemUM.Transaction }
|
||||
private fun TxHistoryLoadingBlock(state: TxHistoryItemsUM.Loading, modifier: Modifier = Modifier) {
|
||||
val lastIndex = state.items.lastIndex
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
var transactionIndex = 0
|
||||
state.items.forEach { item ->
|
||||
when (item) {
|
||||
is TxHistoryUM.TxHistoryItemUM.Title -> TxHistoryLoadingTitle()
|
||||
is TxHistoryUM.TxHistoryItemUM.Transaction -> {
|
||||
val fraction = if (transactionCount <= 1) {
|
||||
0f
|
||||
} else {
|
||||
transactionIndex.toFloat() / (transactionCount - 1)
|
||||
}
|
||||
val alpha = lerp(start = 1f, stop = LOADING_TRANSACTION_MIN_ALPHA, fraction = fraction)
|
||||
TxHistoryLoadingTransaction(modifier = Modifier.alpha(alpha))
|
||||
transactionIndex++
|
||||
}
|
||||
is TxHistoryUM.TxHistoryItemUM.GroupTitle -> Unit
|
||||
}
|
||||
TxHistoryLoadingDateHeader()
|
||||
state.items.forEachIndexed { index, _ ->
|
||||
val fraction = if (lastIndex <= 0) 0f else index.toFloat() / lastIndex
|
||||
val alpha = lerp(start = 1f, stop = LOADING_TRANSACTION_MIN_ALPHA, fraction = fraction)
|
||||
TxHistoryLoadingTransaction(modifier = Modifier.alpha(alpha))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TxHistoryLoadingTitle(modifier: Modifier = Modifier) {
|
||||
private fun TxHistoryLoadingDateHeader(modifier: Modifier = Modifier) {
|
||||
RectangleShimmer(
|
||||
modifier = modifier
|
||||
.padding(
|
||||
|
|
@ -187,7 +242,7 @@ private fun TxHistoryLoadingTransaction(modifier: Modifier = Modifier) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun TxHistoryNotSupportedBlock(state: TxHistoryUM.NotSupported, modifier: Modifier = Modifier) {
|
||||
private fun TxHistoryNotSupportedBlock(state: TxHistoryItemsUM.NotSupported, modifier: Modifier = Modifier) {
|
||||
EmptyTransactionBlock(
|
||||
state = EmptyTransactionsBlockState.NotImplemented(
|
||||
onExplore = state.onExploreClick,
|
||||
|
|
@ -204,7 +259,7 @@ private fun TxHistoryNotSupportedBlock(state: TxHistoryUM.NotSupported, modifier
|
|||
private fun TxHistoryLoadingBlock_Preview() {
|
||||
TangemThemePreviewRedesign {
|
||||
TxHistoryLoadingBlock(
|
||||
state = TxHistoryUM.Loading(
|
||||
state = TxHistoryItemsUM.Loading(
|
||||
isBalanceHidden = false,
|
||||
onExploreClick = {},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ android {
|
|||
namespace = "com.tangem.features.txhistory.impl"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/* Project - API */
|
||||
implementation(projects.features.txhistory.api)
|
||||
|
|
@ -20,6 +24,7 @@ dependencies {
|
|||
implementation(projects.core.ui)
|
||||
implementation(projects.core.utils)
|
||||
implementation(projects.common.routing)
|
||||
implementation(projects.common.ui)
|
||||
implementation(projects.core.configToggles)
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.pagination)
|
||||
|
|
@ -58,4 +63,10 @@ dependencies {
|
|||
implementation(deps.arrow.core)
|
||||
implementation(deps.kotlin.immutable.collections)
|
||||
implementation(deps.decompose.ext.compose)
|
||||
|
||||
/* Tests */
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import androidx.compose.foundation.lazy.LazyListScope
|
|||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.features.txhistory.model.TxHistoryModel
|
||||
import com.tangem.features.txhistory.ui.txHistoryItems
|
||||
|
|
@ -20,14 +21,17 @@ internal class DefaultTxHistoryComponent @AssistedInject constructor(
|
|||
|
||||
private val model: TxHistoryModel = getOrCreateModel(params)
|
||||
|
||||
override val txHistoryState: StateFlow<TxHistoryUM>
|
||||
override val legacyTxHistoryState: StateFlow<TxHistoryUM>
|
||||
get() = model.legacyUiState
|
||||
|
||||
override val txHistoryState: StateFlow<TxHistoryItemsUM>
|
||||
get() = model.uiState
|
||||
|
||||
override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) {
|
||||
txHistoryItemsLegacy(listState, state)
|
||||
}
|
||||
|
||||
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) {
|
||||
override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryItemsUM) {
|
||||
txHistoryItems(listState, state)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,374 @@
|
|||
package com.tangem.features.txhistory.converter
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.tangem.common.ui.account.getResId
|
||||
import com.tangem.common.ui.account.getUiColor
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
|
||||
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.toTimeFormat
|
||||
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.impl.R
|
||||
import com.tangem.features.txhistory.converter.TxHistoryStatusPillConverter.Input as PillInput
|
||||
import com.tangem.features.txhistory.model.TxHistoryLookupContext
|
||||
import com.tangem.features.txhistory.utils.TxHistoryUiActions
|
||||
import com.tangem.utils.StringsSigns
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.isZero
|
||||
import com.tangem.utils.toBriefAddressFormat
|
||||
|
||||
/**
|
||||
* Converts [TxInfo] to [TransactionItemUM] for transaction history.
|
||||
*
|
||||
* Single dispatch: each [TransactionType] is mapped exactly once in [convert] to either a [TransactionItemUM.Pill]
|
||||
* or a [TransactionItemUM.Content]. Per-type metadata (labels, icons, subtitles) lives in one branch — no parallel
|
||||
* `when`s to keep in sync.
|
||||
*
|
||||
* The high cyclomatic complexity of [convert] is structural — it mirrors the [TransactionType] sealed hierarchy.
|
||||
* Splitting it would re-introduce the parallel-`when`s problem; the suppression is intentional.
|
||||
*/
|
||||
internal class TxHistoryItemToTransactionItemUMConverter(
|
||||
private val currency: CryptoCurrency,
|
||||
private val txHistoryUiActions: TxHistoryUiActions,
|
||||
private val lookupContext: TxHistoryLookupContext? = null,
|
||||
) : Converter<TxInfo, TransactionItemUM> {
|
||||
|
||||
private val pillConverter = TxHistoryStatusPillConverter(currency, txHistoryUiActions)
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
override fun convert(value: TxInfo): TransactionItemUM {
|
||||
val uiStatus = value.status.toUiStatus()
|
||||
return when (val type = value.type) {
|
||||
// region Pill
|
||||
is TransactionType.Approve -> pillConverter.convert(PillInput(value, uiStatus, ApproveSpec))
|
||||
is TransactionType.Staking.Stake -> pillConverter.convert(PillInput(value, uiStatus, StakeSpec))
|
||||
is TransactionType.Staking.Unstake -> pillConverter.convert(PillInput(value, uiStatus, UnstakeSpec))
|
||||
is TransactionType.Staking.Restake -> pillConverter.convert(PillInput(value, uiStatus, RestakeSpec))
|
||||
is TransactionType.Staking.Vote -> pillConverter.convert(PillInput(value, uiStatus, VoteSpec))
|
||||
is TransactionType.Staking.Withdraw -> pillConverter.convert(PillInput(value, uiStatus, WithdrawSpec))
|
||||
is TransactionType.YieldSupply.Enter -> pillConverter.convert(PillInput(value, uiStatus, YieldEnterSpec))
|
||||
is TransactionType.YieldSupply.Exit -> pillConverter.convert(PillInput(value, uiStatus, YieldExitSpec))
|
||||
// endregion
|
||||
|
||||
// region Content
|
||||
is TransactionType.Operation -> operationContent(value, uiStatus, type)
|
||||
is TransactionType.Swap -> swapContent(value, uiStatus)
|
||||
is TransactionType.Transfer -> transferContent(value, uiStatus)
|
||||
is TransactionType.Staking.ClaimRewards -> claimRewardsContent(value, uiStatus)
|
||||
is TransactionType.YieldSupply.Topup -> yieldTopupContent(value, uiStatus, type)
|
||||
is TransactionType.YieldSupply.Send -> yieldSendContent(value, uiStatus, type)
|
||||
is TransactionType.YieldSupply.DeployContract -> yieldDeployContractContent(value, uiStatus, type)
|
||||
is TransactionType.YieldSupply.InitializeToken -> yieldInitializeTokenContent(value, uiStatus, type)
|
||||
is TransactionType.YieldSupply.ReactivateToken -> yieldReactivateTokenContent(value, uiStatus, type)
|
||||
is TransactionType.UnknownOperation -> unknownOperationContent(value, uiStatus)
|
||||
is TransactionType.GaslessFee -> gaslessFeeContent(value, uiStatus)
|
||||
// endregion
|
||||
}
|
||||
}
|
||||
|
||||
private fun operationContent(
|
||||
tx: TxInfo,
|
||||
uiStatus: TransactionItemUM.Content.Status,
|
||||
type: TransactionType.Operation,
|
||||
): TransactionItemUM.Content = buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = stringReference(type.name),
|
||||
iconRes = tx.directionalIcon(),
|
||||
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
|
||||
)
|
||||
|
||||
private fun swapContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
|
||||
buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
|
||||
iconRes = tx.directionalIcon(),
|
||||
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
|
||||
)
|
||||
|
||||
private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content {
|
||||
val counterpartyAddress = (tx.interactionAddressType as? TxInfo.InteractionAddressType.User)?.address
|
||||
val direction = if (tx.isOutgoing) ContentSubtitle.Direction.TO else ContentSubtitle.Direction.FROM
|
||||
val ownSubtitle = counterpartyAddress?.let { resolveOwnSubtitle(lookupContext, it, direction) }
|
||||
|
||||
val title = when {
|
||||
ownSubtitle != null -> tx.statusAwareTitle(R.string.common_transfer, R.string.common_transferred)
|
||||
tx.isOutgoing -> tx.statusAwareTitle(R.string.common_sending, R.string.common_sent)
|
||||
else -> tx.statusAwareTitle(R.string.common_receiving, R.string.common_received)
|
||||
}
|
||||
|
||||
val subtitle = ownSubtitle ?: when {
|
||||
counterpartyAddress != null -> ContentSubtitle.ExternalAddress(
|
||||
direction = direction,
|
||||
rawAddress = counterpartyAddress,
|
||||
briefAddress = counterpartyAddress.toBriefAddressFormat(),
|
||||
)
|
||||
else -> ContentSubtitle.Plain(tx.extractSubtitleByAddressType())
|
||||
}
|
||||
|
||||
return buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = title,
|
||||
iconRes = tx.directionalIcon(),
|
||||
subtitle = subtitle,
|
||||
)
|
||||
}
|
||||
|
||||
private fun claimRewardsContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
|
||||
buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = tx.statusAwareTitle(
|
||||
pending = R.string.transaction_history_claiming_reward,
|
||||
confirmed = R.string.transaction_history_staking_reward,
|
||||
),
|
||||
iconRes = R.drawable.ic_transaction_history_claim_rewards_24,
|
||||
subtitle = ContentSubtitle.Plain(resourceReference(R.string.transaction_history_earned_from_stake)),
|
||||
)
|
||||
|
||||
private fun yieldTopupContent(
|
||||
tx: TxInfo,
|
||||
uiStatus: TransactionItemUM.Content.Status,
|
||||
type: TransactionType.YieldSupply.Topup,
|
||||
): TransactionItemUM.Content = buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = resourceReference(R.string.yield_module_transaction_topup),
|
||||
iconRes = tx.directionalIcon(),
|
||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
||||
)
|
||||
|
||||
private fun yieldDeployContractContent(
|
||||
tx: TxInfo,
|
||||
uiStatus: TransactionItemUM.Content.Status,
|
||||
type: TransactionType.YieldSupply.DeployContract,
|
||||
): TransactionItemUM.Content = buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = resourceReference(R.string.yield_module_transaction_deploy_contract),
|
||||
iconRes = R.drawable.ic_doc_24,
|
||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
||||
)
|
||||
|
||||
private fun yieldInitializeTokenContent(
|
||||
tx: TxInfo,
|
||||
uiStatus: TransactionItemUM.Content.Status,
|
||||
type: TransactionType.YieldSupply.InitializeToken,
|
||||
): TransactionItemUM.Content = buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = resourceReference(R.string.yield_module_transaction_initialize),
|
||||
iconRes = R.drawable.ic_gear_24,
|
||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
||||
)
|
||||
|
||||
private fun yieldReactivateTokenContent(
|
||||
tx: TxInfo,
|
||||
uiStatus: TransactionItemUM.Content.Status,
|
||||
type: TransactionType.YieldSupply.ReactivateToken,
|
||||
): TransactionItemUM.Content = buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = resourceReference(R.string.yield_module_transaction_reactivate),
|
||||
iconRes = R.drawable.ic_refresh_24,
|
||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
||||
)
|
||||
|
||||
private fun yieldSendContent(
|
||||
tx: TxInfo,
|
||||
uiStatus: TransactionItemUM.Content.Status,
|
||||
type: TransactionType.YieldSupply.Send,
|
||||
): TransactionItemUM.Content = buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = if (type.isYieldSupplyWithdraw || tx.isOutgoing) {
|
||||
resourceReference(R.string.yield_module_transaction_withdraw)
|
||||
} else {
|
||||
resourceReference(R.string.common_transfer)
|
||||
},
|
||||
iconRes = tx.directionalIcon(),
|
||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
||||
hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing,
|
||||
)
|
||||
|
||||
private fun unknownOperationContent(
|
||||
tx: TxInfo,
|
||||
uiStatus: TransactionItemUM.Content.Status,
|
||||
): TransactionItemUM.Content = buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = resourceReference(R.string.transaction_history_operation),
|
||||
iconRes = tx.directionalIcon(),
|
||||
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
|
||||
)
|
||||
|
||||
private fun gaslessFeeContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
|
||||
buildContent(
|
||||
tx = tx,
|
||||
uiStatus = uiStatus,
|
||||
title = resourceReference(R.string.gasless_transaction_fee),
|
||||
iconRes = tx.directionalIcon(),
|
||||
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
|
||||
)
|
||||
|
||||
private fun buildContent(
|
||||
tx: TxInfo,
|
||||
uiStatus: TransactionItemUM.Content.Status,
|
||||
title: TextReference,
|
||||
iconRes: Int,
|
||||
subtitle: ContentSubtitle,
|
||||
hideAmount: Boolean = false,
|
||||
): TransactionItemUM.Content = TransactionItemUM.Content(
|
||||
txHash = tx.txHash,
|
||||
amount = if (hideAmount) "" else tx.formatContentAmount(currency),
|
||||
currencySymbol = if (hideAmount) "" else currency.symbol,
|
||||
time = tx.timestampInMillis.toTimeFormat(),
|
||||
status = uiStatus,
|
||||
direction = tx.extractDirection(),
|
||||
iconRes = if (uiStatus is TransactionItemUM.Content.Status.Failed) R.drawable.ic_close_24 else iconRes,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
timestamp = tx.timestampInMillis,
|
||||
onClick = { txHistoryUiActions.openTxInExplorer(tx.txHash) },
|
||||
)
|
||||
}
|
||||
|
||||
// region Content building helpers
|
||||
|
||||
private fun TxInfo.formatContentAmount(currency: CryptoCurrency): String {
|
||||
val prefix = when {
|
||||
status is TxInfo.TransactionStatus.Failed -> ""
|
||||
amount.isZero() -> ""
|
||||
type is TransactionType.Staking.ClaimRewards -> ""
|
||||
else -> if (isOutgoing) StringsSigns.MINUS else StringsSigns.PLUS
|
||||
}
|
||||
return prefix + amount.format { crypto(symbol = "", decimals = currency.decimals) }.trim()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Subtitles
|
||||
|
||||
private fun resolveOwnSubtitle(
|
||||
lookupContext: TxHistoryLookupContext?,
|
||||
address: String,
|
||||
direction: ContentSubtitle.Direction,
|
||||
): ContentSubtitle? {
|
||||
val ctx = lookupContext ?: return null
|
||||
val account = ctx.ownAccountByAddress[address] ?: return null
|
||||
return if (ctx.isAccountsModeEnabled) {
|
||||
ContentSubtitle.OwnAccount(
|
||||
direction = direction,
|
||||
accountName = account.accountName.toUM().value,
|
||||
iconResId = account.icon.value.getResId(),
|
||||
iconBackgroundColor = account.icon.color.getUiColor(),
|
||||
)
|
||||
} else {
|
||||
val walletInfo = ctx.walletInfoById[account.accountId.userWalletId] ?: return null
|
||||
ContentSubtitle.OwnWallet(
|
||||
direction = direction,
|
||||
walletName = walletInfo.name,
|
||||
deviceIconUM = walletInfo.deviceIconUM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TxInfo.yieldSupplySubtitle(currency: CryptoCurrency, type: TransactionType.YieldSupply): TextReference {
|
||||
if (currency is CryptoCurrency.Coin) {
|
||||
return if (type is TransactionType.YieldSupply.Send) {
|
||||
extractSubtitleByAddressType()
|
||||
} else {
|
||||
resourceReference(
|
||||
R.string.transaction_history_transaction_for_address,
|
||||
wrappedList(type.address?.toBriefAddressFormat().orEmpty()),
|
||||
)
|
||||
}
|
||||
}
|
||||
return when (type) {
|
||||
is TransactionType.YieldSupply.Enter ->
|
||||
amountSubtitle(currency, R.string.yield_module_transaction_enter_subtitle)
|
||||
TransactionType.YieldSupply.Topup ->
|
||||
amountSubtitle(currency, R.string.yield_module_transaction_topup_subtitle)
|
||||
is TransactionType.YieldSupply.Exit ->
|
||||
amountSubtitle(currency, R.string.yield_module_transaction_exit_subtitle)
|
||||
is TransactionType.YieldSupply.Send -> if (!isOutgoing && type.isYieldSupplyWithdraw) {
|
||||
amountSubtitle(currency, R.string.yield_module_transaction_exit_subtitle)
|
||||
} else {
|
||||
extractSubtitleByAddressType()
|
||||
}
|
||||
else -> extractSubtitleByAddressType()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TxInfo.amountSubtitle(currency: CryptoCurrency, @StringRes resId: Int): TextReference {
|
||||
val formatted = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
|
||||
return resourceReference(resId, wrappedList(formatted))
|
||||
}
|
||||
|
||||
private fun TxInfo.extractSubtitleByAddressType(): TextReference =
|
||||
when (val interactionAddress = interactionAddressType) {
|
||||
is TxInfo.InteractionAddressType.Contract -> resourceReference(
|
||||
id = R.string.transaction_history_contract_address,
|
||||
formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()),
|
||||
)
|
||||
is TxInfo.InteractionAddressType.Multiple -> resourceReference(
|
||||
id = directionalAddressRes(),
|
||||
formatArgs = wrappedList(resourceReference(R.string.transaction_history_multiple_addresses)),
|
||||
)
|
||||
is TxInfo.InteractionAddressType.User -> resourceReference(
|
||||
id = directionalAddressRes(),
|
||||
formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()),
|
||||
)
|
||||
is TxInfo.InteractionAddressType.Validator -> resourceReference(
|
||||
id = R.string.transaction_history_transaction_validator,
|
||||
formatArgs = wrappedList(interactionAddress.address.toBriefAddressFormat()),
|
||||
)
|
||||
null -> TextReference.EMPTY
|
||||
}
|
||||
|
||||
private fun TxInfo.directionalAddressRes(): Int = if (isOutgoing) {
|
||||
R.string.transaction_history_transaction_to_address
|
||||
} else {
|
||||
R.string.transaction_history_transaction_from_address
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Labels
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Misc
|
||||
|
||||
private fun TxInfo.directionalIcon(): Int = if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24
|
||||
|
||||
private fun TxInfo.extractDirection(): TransactionItemUM.Content.Direction = if (isOutgoing) {
|
||||
TransactionItemUM.Content.Direction.OUTGOING
|
||||
} else {
|
||||
TransactionItemUM.Content.Direction.INCOMING
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
package com.tangem.features.txhistory.converter
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.PillKind
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
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.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.network.TxInfo.TransactionType
|
||||
import com.tangem.features.txhistory.impl.R
|
||||
import com.tangem.features.txhistory.utils.TxHistoryUiActions
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.toBriefAddressFormat
|
||||
|
||||
internal class TxHistoryStatusPillConverter(
|
||||
private val currency: CryptoCurrency,
|
||||
private val txHistoryUiActions: TxHistoryUiActions,
|
||||
) : Converter<TxHistoryStatusPillConverter.Input, TransactionItemUM.Pill> {
|
||||
|
||||
override fun convert(value: Input): TransactionItemUM.Pill {
|
||||
val tx = value.tx
|
||||
val uiStatus = value.uiStatus
|
||||
val spec = value.spec
|
||||
val hasAmount = spec.amount.show(uiStatus)
|
||||
return TransactionItemUM.Pill(
|
||||
txHash = tx.txHash,
|
||||
kind = spec.kind,
|
||||
status = uiStatus,
|
||||
label = spec.labels.resolve(uiStatus),
|
||||
amount = if (hasAmount) {
|
||||
tx.amount.format { crypto(symbol = "", decimals = currency.decimals) }.trim()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
currencySymbol = if (hasAmount) currency.symbol else null,
|
||||
subtitle = tx.buildPillSubtitle(uiStatus),
|
||||
timestamp = tx.timestampInMillis,
|
||||
onClick = { txHistoryUiActions.openTxInExplorer(tx.txHash) },
|
||||
)
|
||||
}
|
||||
|
||||
data class Input(
|
||||
val tx: TxInfo,
|
||||
val uiStatus: TransactionItemUM.Content.Status,
|
||||
val spec: PillSpec,
|
||||
)
|
||||
}
|
||||
|
||||
internal data class PillSpec(
|
||||
val kind: PillKind,
|
||||
val labels: PillLabels,
|
||||
val amount: PillAmount,
|
||||
)
|
||||
|
||||
internal data class PillLabels(
|
||||
@StringRes val confirmed: Int,
|
||||
@StringRes val pending: Int,
|
||||
@StringRes val failedBase: Int = pending,
|
||||
val hasFailedTemplate: Boolean = true,
|
||||
)
|
||||
|
||||
internal enum class PillAmount {
|
||||
ALWAYS, NEVER, IF_NOT_FAILED;
|
||||
|
||||
fun show(status: TransactionItemUM.Content.Status): Boolean = when (this) {
|
||||
ALWAYS -> true
|
||||
NEVER -> false
|
||||
IF_NOT_FAILED -> status !is TransactionItemUM.Content.Status.Failed
|
||||
}
|
||||
}
|
||||
|
||||
internal val ApproveSpec = PillSpec(
|
||||
kind = PillKind.APPROVE,
|
||||
labels = PillLabels(
|
||||
confirmed = R.string.common_approved,
|
||||
pending = R.string.common_approving,
|
||||
hasFailedTemplate = false,
|
||||
),
|
||||
amount = PillAmount.ALWAYS,
|
||||
)
|
||||
internal val StakeSpec = PillSpec(
|
||||
kind = PillKind.STAKING,
|
||||
labels = PillLabels(R.string.common_staked, R.string.common_staking),
|
||||
amount = PillAmount.IF_NOT_FAILED,
|
||||
)
|
||||
internal val UnstakeSpec = PillSpec(
|
||||
kind = PillKind.STAKING,
|
||||
labels = PillLabels(R.string.staking_unstaked, R.string.staking_unstaking),
|
||||
amount = PillAmount.IF_NOT_FAILED,
|
||||
)
|
||||
internal val RestakeSpec = PillSpec(
|
||||
kind = PillKind.STAKING,
|
||||
labels = PillLabels(
|
||||
confirmed = R.string.transaction_history_rewards_restaked,
|
||||
pending = R.string.transaction_history_rewards_restaking,
|
||||
),
|
||||
amount = PillAmount.IF_NOT_FAILED,
|
||||
)
|
||||
internal val VoteSpec = PillSpec(
|
||||
kind = PillKind.STAKING,
|
||||
labels = PillLabels(
|
||||
confirmed = R.string.staking_vote,
|
||||
pending = R.string.common_voting,
|
||||
failedBase = R.string.staking_vote,
|
||||
),
|
||||
amount = PillAmount.NEVER,
|
||||
)
|
||||
internal val WithdrawSpec = PillSpec(
|
||||
kind = PillKind.STAKING,
|
||||
labels = PillLabels(
|
||||
confirmed = R.string.staking_withdraw,
|
||||
pending = R.string.common_withdrawing,
|
||||
failedBase = R.string.staking_withdraw,
|
||||
),
|
||||
amount = PillAmount.NEVER,
|
||||
)
|
||||
internal val YieldEnterSpec = PillSpec(
|
||||
kind = PillKind.YIELD_MODE,
|
||||
labels = PillLabels(
|
||||
confirmed = R.string.yield_module_transaction_enter,
|
||||
pending = R.string.yield_module_token_details_earn_notification_processing,
|
||||
failedBase = R.string.common_yield_mode,
|
||||
),
|
||||
amount = PillAmount.NEVER,
|
||||
)
|
||||
internal val YieldExitSpec = PillSpec(
|
||||
kind = PillKind.YIELD_MODE,
|
||||
labels = PillLabels(
|
||||
confirmed = R.string.yield_module_transaction_exit,
|
||||
pending = R.string.transaction_history_disabling_yield_mode,
|
||||
),
|
||||
amount = PillAmount.NEVER,
|
||||
)
|
||||
|
||||
private fun TxInfo.buildPillSubtitle(status: TransactionItemUM.Content.Status): TransactionItemUM.PillSubtitle? {
|
||||
if (type !is TransactionType.Approve) return null
|
||||
if (status is TransactionItemUM.Content.Status.Failed) return null
|
||||
val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null
|
||||
return TransactionItemUM.PillSubtitle.Address(
|
||||
rawAddress = address,
|
||||
briefAddress = address.toBriefAddressFormat(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun PillLabels.resolve(status: TransactionItemUM.Content.Status): TextReference = when (status) {
|
||||
is TransactionItemUM.Content.Status.Confirmed -> resourceReference(confirmed)
|
||||
is TransactionItemUM.Content.Status.Unconfirmed -> resourceReference(pending)
|
||||
is TransactionItemUM.Content.Status.Failed -> if (hasFailedTemplate) {
|
||||
resourceReference(R.string.common_action_failed, wrappedList(resourceReference(failedBase)))
|
||||
} else {
|
||||
resourceReference(failedBase)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.features.txhistory.model
|
||||
|
||||
import com.tangem.core.ui.ds.image.DeviceIconUM
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
|
||||
/**
|
||||
* Per-page lookup context for the tx-history converter.
|
||||
*
|
||||
* - [ownAccountByAddress] / [walletInfoById] — address-keyed lookups for resolving counterparty owners
|
||||
* in transfer subtitles ("to / from MY account / wallet").
|
||||
* - [isAccountsModeEnabled] — toggles whether a resolved owner is rendered as account or wallet.
|
||||
*/
|
||||
internal data class TxHistoryLookupContext(
|
||||
val ownAccountByAddress: Map<String, Account.CryptoPortfolio>,
|
||||
val isAccountsModeEnabled: Boolean,
|
||||
val walletInfoById: Map<UserWalletId, WalletInfo>,
|
||||
)
|
||||
|
||||
internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM)
|
||||
|
|
@ -2,30 +2,49 @@ package com.tangem.features.txhistory.model
|
|||
|
||||
import androidx.compose.runtime.Stable
|
||||
import arrow.core.Option
|
||||
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
|
||||
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.navigation.url.UrlOpener
|
||||
import com.tangem.core.ui.DesignFeatureToggles
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountStatus
|
||||
import com.tangem.domain.models.account.filterCryptoPortfolio
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.txhistory.models.TxHistoryStateError
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
|
||||
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
|
||||
import com.tangem.features.txhistory.component.TxHistoryComponent
|
||||
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter
|
||||
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUpdateListener
|
||||
import com.tangem.features.txhistory.state.TxHistoryStateController
|
||||
import com.tangem.features.txhistory.utils.TxHistoryListManager
|
||||
import com.tangem.features.txhistory.utils.TxHistoryUiActions
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import javax.inject.Inject
|
||||
|
|
@ -38,29 +57,66 @@ internal class TxHistoryModel @Inject constructor(
|
|||
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
|
||||
private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
|
||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||
private val getWalletIconUseCase: GetWalletIconUseCase,
|
||||
private val walletIconUMConverter: WalletIconUMConverter,
|
||||
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
|
||||
private val urlOpener: UrlOpener,
|
||||
private val txHistoryUpdateListener: TxHistoryUpdateListener,
|
||||
private val stateController: TxHistoryStateController,
|
||||
private val designFeatureToggles: DesignFeatureToggles,
|
||||
repository: TxHistoryRepositoryV2,
|
||||
paramsContainer: ParamsContainer,
|
||||
multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
|
||||
isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
|
||||
userWalletsListRepository: UserWalletsListRepository,
|
||||
) : Model(), TxHistoryUiActions {
|
||||
|
||||
private val params: TxHistoryComponent.Params = paramsContainer.require()
|
||||
private val txHistoryItemConverter =
|
||||
|
||||
private val lookupDataFlow: Flow<TxHistoryLookupContext> = if (designFeatureToggles.isRedesignEnabled) {
|
||||
combine(
|
||||
flow = multiAccountStatusListSupplier(),
|
||||
flow2 = isAccountsModeEnabledUseCase(),
|
||||
flow3 = userWalletsListRepository.userWallets.filterNotNull(),
|
||||
transform = ::Triple,
|
||||
)
|
||||
.map { (accountLists, modeEnabled, wallets) ->
|
||||
TxHistoryLookupContext(
|
||||
ownAccountByAddress = buildOwnAccountAddressMap(accountLists),
|
||||
isAccountsModeEnabled = modeEnabled,
|
||||
walletInfoById = wallets.associate { wallet ->
|
||||
wallet.walletId to WalletInfo(
|
||||
name = wallet.name,
|
||||
deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(wallet)),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
.shareIn(modelScope, SharingStarted.WhileSubscribed(), replay = 1)
|
||||
} else {
|
||||
emptyFlow()
|
||||
}
|
||||
|
||||
private val legacyTxHistoryItemConverter =
|
||||
TxHistoryItemToTransactionStateConverter(currency = params.currency, txHistoryUiActions = this)
|
||||
private val txHistoryListManager = TxHistoryListManager(
|
||||
repository = repository,
|
||||
dispatchers = dispatchers,
|
||||
userWalletId = params.userWalletId,
|
||||
currency = params.currency,
|
||||
txHistoryItemConverter = txHistoryItemConverter,
|
||||
designFeatureToggles = designFeatureToggles,
|
||||
txHistoryUiActions = this,
|
||||
lookupDataFlow = lookupDataFlow,
|
||||
legacyTxHistoryItemConverter = legacyTxHistoryItemConverter,
|
||||
)
|
||||
private val _uiState: MutableStateFlow<TxHistoryUM> =
|
||||
MutableStateFlow(TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = ::openExplorer))
|
||||
val uiState: StateFlow<TxHistoryUM> = _uiState.asStateFlow()
|
||||
|
||||
val legacyUiState = stateController.legacyUiState
|
||||
val uiState = stateController.uiState
|
||||
|
||||
init {
|
||||
stateController.setLoading(isBalanceHidden = true, onExploreClick = ::openExplorer)
|
||||
handleBalanceHiding()
|
||||
subscribeToUiItemChanges()
|
||||
initListManager()
|
||||
|
|
@ -69,9 +125,26 @@ internal class TxHistoryModel @Inject constructor(
|
|||
subscribeOnCurrencyStatusUpdates()
|
||||
}
|
||||
|
||||
private fun buildOwnAccountAddressMap(lists: List<AccountStatusList>): Map<String, Account.CryptoPortfolio> {
|
||||
val networkRawId = params.currency.network.id.rawId
|
||||
val map = mutableMapOf<String, Account.CryptoPortfolio>()
|
||||
lists.forEach { accountList ->
|
||||
accountList.accountStatuses
|
||||
.filterCryptoPortfolio()
|
||||
.forEach { status: AccountStatus.CryptoPortfolio ->
|
||||
status.flattenCurrencies().forEach { currencyStatus ->
|
||||
if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach
|
||||
val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach
|
||||
map[address] = status.account
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private fun subscribeToUiItemChanges() {
|
||||
txHistoryListManager.uiItems
|
||||
.onEach { updateState(it) }
|
||||
.onEach { snapshot -> stateController.setContent(snapshot = snapshot, loadMore = ::loadMoreItems) }
|
||||
.launchIn(modelScope)
|
||||
txHistoryListManager.paginationStatus
|
||||
.onEach { paginationStatus -> handlePaginationStatus(paginationStatus) }
|
||||
|
|
@ -89,7 +162,7 @@ internal class TxHistoryModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun loadTxInfo() {
|
||||
_uiState.update { state -> getLoadingState(state.isBalanceHidden) }
|
||||
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
|
||||
modelScope.launch {
|
||||
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
|
||||
.onLeft(::handleErrorState)
|
||||
|
|
@ -98,12 +171,9 @@ internal class TxHistoryModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun reload() {
|
||||
// fast exit
|
||||
if (uiState.value is TxHistoryUM.NotSupported) return
|
||||
if (stateController.isNotSupported) return
|
||||
|
||||
_uiState.update { state ->
|
||||
if (state !is TxHistoryUM.Content) getLoadingState(state.isBalanceHidden) else state
|
||||
}
|
||||
stateController.setLoadingIfNotContent(onExploreClick = ::openExplorer)
|
||||
modelScope.launch {
|
||||
txHistoryItemsCountUseCase.invoke(userWalletId = params.userWalletId, currency = params.currency)
|
||||
.onLeft(::handleErrorState)
|
||||
|
|
@ -113,7 +183,9 @@ internal class TxHistoryModel @Inject constructor(
|
|||
|
||||
private fun handleBalanceHiding() {
|
||||
getBalanceHidingSettingsUseCase()
|
||||
.onEach { _uiState.update { state -> state.copySealed(isBalanceHidden = it.isBalanceHidden) } }
|
||||
.map { it.isBalanceHidden }
|
||||
.distinctUntilChanged()
|
||||
.onEach(stateController::updateBalanceHidden)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
|
|
@ -122,85 +194,70 @@ internal class TxHistoryModel @Inject constructor(
|
|||
return true
|
||||
}
|
||||
|
||||
private fun updateState(items: ImmutableList<TxHistoryUM.TxHistoryItemUM>) {
|
||||
_uiState.update { state ->
|
||||
if (state is TxHistoryUM.Content) {
|
||||
state.copy(items = items)
|
||||
} else {
|
||||
TxHistoryUM.Content(
|
||||
items = items,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
loadMore = ::loadMoreItems,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePaginationStatus(status: PaginationStatus<*>) {
|
||||
_uiState.update { state ->
|
||||
when (status) {
|
||||
is PaginationStatus.InitialLoadingError -> getErrorState(state.isBalanceHidden)
|
||||
PaginationStatus.EndOfPagination,
|
||||
PaginationStatus.InitialLoading,
|
||||
PaginationStatus.NextBatchLoading,
|
||||
PaginationStatus.None,
|
||||
is PaginationStatus.Paginating<*>,
|
||||
-> state
|
||||
}
|
||||
when (status) {
|
||||
is PaginationStatus.InitialLoadingError -> stateController.setError(
|
||||
onReloadClick = ::reload,
|
||||
onExploreClick = ::openExplorer,
|
||||
)
|
||||
PaginationStatus.NextBatchLoading -> stateController.updateLoadingMore(isLoadingMore = true)
|
||||
PaginationStatus.EndOfPagination,
|
||||
is PaginationStatus.Paginating<*>,
|
||||
-> stateController.updateLoadingMore(isLoadingMore = false)
|
||||
PaginationStatus.InitialLoading,
|
||||
PaginationStatus.None,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleErrorState(error: TxHistoryStateError) {
|
||||
_uiState.update { state ->
|
||||
when (error) {
|
||||
is TxHistoryStateError.DataError -> getErrorState(isBalanceHidden = state.isBalanceHidden)
|
||||
TxHistoryStateError.EmptyTxHistories -> TxHistoryUM.Empty(
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
onExploreClick = ::openExplorer,
|
||||
)
|
||||
TxHistoryStateError.TxHistoryNotImplemented -> TxHistoryUM.NotSupported(
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
pendingTransactions = persistentListOf(),
|
||||
onExploreClick = ::openExplorer,
|
||||
)
|
||||
}
|
||||
when (error) {
|
||||
is TxHistoryStateError.DataError -> stateController.setError(
|
||||
onReloadClick = ::reload,
|
||||
onExploreClick = ::openExplorer,
|
||||
)
|
||||
TxHistoryStateError.EmptyTxHistories -> stateController.setEmpty(onExploreClick = ::openExplorer)
|
||||
TxHistoryStateError.TxHistoryNotImplemented -> stateController.setNotSupported(
|
||||
onExploreClick = ::openExplorer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getErrorState(isBalanceHidden: Boolean): TxHistoryUM.Error {
|
||||
return TxHistoryUM.Error(
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
onReloadClick = ::reload,
|
||||
onExploreClick = ::openExplorer,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getLoadingState(isBalanceHidden: Boolean): TxHistoryUM.Loading {
|
||||
return TxHistoryUM.Loading(isBalanceHidden = isBalanceHidden, onExploreClick = ::openExplorer)
|
||||
}
|
||||
|
||||
private fun subscribeOnCurrencyStatusUpdates() {
|
||||
singleAccountStatusListSupplier(params.userWalletId)
|
||||
val statusFlow = singleAccountStatusListSupplier(params.userWalletId)
|
||||
.map { it.getCryptoCurrencyStatus(currency = params.currency) }
|
||||
.distinctUntilChanged()
|
||||
.onEach(::handlePendingTxsChanges)
|
||||
|
||||
val combined: Flow<Pair<Option<CryptoCurrencyStatus>, TxHistoryLookupContext?>> =
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
combine(statusFlow, lookupDataFlow) { status, lookup -> status to lookup }
|
||||
} else {
|
||||
statusFlow.map { it to null }
|
||||
}
|
||||
|
||||
combined
|
||||
.onEach { (status, lookup) -> handlePendingTxsChanges(status, lookup) }
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(modelScope)
|
||||
}
|
||||
|
||||
private fun handlePendingTxsChanges(maybeCurrencyStatus: Option<CryptoCurrencyStatus>) {
|
||||
private fun handlePendingTxsChanges(
|
||||
maybeCurrencyStatus: Option<CryptoCurrencyStatus>,
|
||||
lookupContext: TxHistoryLookupContext?,
|
||||
) {
|
||||
maybeCurrencyStatus.onSome { status ->
|
||||
val pendingTxs = status.value.pendingTransactions
|
||||
.map(txHistoryItemConverter::convert)
|
||||
.toPersistentList()
|
||||
|
||||
_uiState.update { state ->
|
||||
if (state is TxHistoryUM.NotSupported) {
|
||||
state.copy(pendingTransactions = pendingTxs)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
val pending = status.value.pendingTransactions
|
||||
stateController.updatePendingTransactions(
|
||||
pendingTxs = {
|
||||
val converter = TxHistoryItemToTransactionItemUMConverter(
|
||||
currency = params.currency,
|
||||
txHistoryUiActions = this,
|
||||
lookupContext = lookupContext,
|
||||
)
|
||||
pending.map(converter::convert).toPersistentList()
|
||||
},
|
||||
legacyPendingTxs = { pending.map(legacyTxHistoryItemConverter::convert).toPersistentList() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.features.txhistory.state
|
||||
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Snapshot of transaction history items emitted by [TxHistoryListManager]. Wraps either the
|
||||
* primary or legacy item list so that one [Flow] can carry both pipelines, with the active
|
||||
* variant chosen via the design feature toggle.
|
||||
*/
|
||||
internal sealed interface TxHistoryItemsSnapshot {
|
||||
|
||||
data class Items(val items: ImmutableList<TxHistoryItemsUM.TxHistoryItemUM>) : TxHistoryItemsSnapshot
|
||||
|
||||
data class LegacyItems(val items: ImmutableList<TxHistoryUM.TxHistoryItemUM>) : TxHistoryItemsSnapshot
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package com.tangem.features.txhistory.state
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.ui.DesignFeatureToggles
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionState
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Owns the transaction history UI state and routes updates to either [legacyUiState] or
|
||||
* [uiState] based on [DesignFeatureToggles.isRedesignEnabled]. Only the active pipeline gets
|
||||
* emitted to; the inactive flow stays at its initial Loading value.
|
||||
*/
|
||||
@ModelScoped
|
||||
internal class TxHistoryStateController @Inject constructor(
|
||||
private val designFeatureToggles: DesignFeatureToggles,
|
||||
) {
|
||||
|
||||
private val _legacyUiState: MutableStateFlow<TxHistoryUM> =
|
||||
MutableStateFlow(TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = {}))
|
||||
val legacyUiState: StateFlow<TxHistoryUM> = _legacyUiState
|
||||
|
||||
private val _uiState: MutableStateFlow<TxHistoryItemsUM> =
|
||||
MutableStateFlow(TxHistoryItemsUM.Loading(isBalanceHidden = true, onExploreClick = {}))
|
||||
val uiState: StateFlow<TxHistoryItemsUM> = _uiState
|
||||
|
||||
val isNotSupported: Boolean
|
||||
get() = if (designFeatureToggles.isRedesignEnabled) {
|
||||
_uiState.value is TxHistoryItemsUM.NotSupported
|
||||
} else {
|
||||
_legacyUiState.value is TxHistoryUM.NotSupported
|
||||
}
|
||||
|
||||
fun setLoading(isBalanceHidden: Boolean, onExploreClick: () -> Unit) {
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
_uiState.value = TxHistoryItemsUM.Loading(
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
onExploreClick = onExploreClick,
|
||||
)
|
||||
} else {
|
||||
_legacyUiState.value = TxHistoryUM.Loading(
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
onExploreClick = onExploreClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLoadingIfNotContent(onExploreClick: () -> Unit) {
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
_uiState.update { state ->
|
||||
state as? TxHistoryItemsUM.Content ?: TxHistoryItemsUM.Loading(state.isBalanceHidden, onExploreClick)
|
||||
}
|
||||
} else {
|
||||
_legacyUiState.update { state ->
|
||||
state as? TxHistoryUM.Content ?: TxHistoryUM.Loading(state.isBalanceHidden, onExploreClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setError(onReloadClick: () -> Unit, onExploreClick: () -> Unit) {
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
_uiState.value = TxHistoryItemsUM.Error(
|
||||
isBalanceHidden = _uiState.value.isBalanceHidden,
|
||||
onReloadClick = onReloadClick,
|
||||
onExploreClick = onExploreClick,
|
||||
)
|
||||
} else {
|
||||
_legacyUiState.value = TxHistoryUM.Error(
|
||||
isBalanceHidden = _legacyUiState.value.isBalanceHidden,
|
||||
onReloadClick = onReloadClick,
|
||||
onExploreClick = onExploreClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setEmpty(onExploreClick: () -> Unit) {
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
_uiState.value = TxHistoryItemsUM.Empty(
|
||||
isBalanceHidden = _uiState.value.isBalanceHidden,
|
||||
onExploreClick = onExploreClick,
|
||||
)
|
||||
} else {
|
||||
_legacyUiState.value = TxHistoryUM.Empty(
|
||||
isBalanceHidden = _legacyUiState.value.isBalanceHidden,
|
||||
onExploreClick = onExploreClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setNotSupported(onExploreClick: () -> Unit) {
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
_uiState.value = TxHistoryItemsUM.NotSupported(
|
||||
isBalanceHidden = _uiState.value.isBalanceHidden,
|
||||
pendingTransactions = persistentListOf(),
|
||||
onExploreClick = onExploreClick,
|
||||
)
|
||||
} else {
|
||||
_legacyUiState.value = TxHistoryUM.NotSupported(
|
||||
isBalanceHidden = _legacyUiState.value.isBalanceHidden,
|
||||
pendingTransactions = persistentListOf(),
|
||||
onExploreClick = onExploreClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setContent(snapshot: TxHistoryItemsSnapshot, loadMore: () -> Boolean) {
|
||||
when (snapshot) {
|
||||
is TxHistoryItemsSnapshot.Items -> _uiState.update { state ->
|
||||
if (state is TxHistoryItemsUM.Content) {
|
||||
state.copy(items = snapshot.items)
|
||||
} else {
|
||||
TxHistoryItemsUM.Content(
|
||||
items = snapshot.items,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
isLoadingMore = false,
|
||||
loadMore = loadMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
is TxHistoryItemsSnapshot.LegacyItems -> _legacyUiState.update { state ->
|
||||
if (state is TxHistoryUM.Content) {
|
||||
state.copy(items = snapshot.items)
|
||||
} else {
|
||||
TxHistoryUM.Content(
|
||||
items = snapshot.items,
|
||||
isBalanceHidden = state.isBalanceHidden,
|
||||
loadMore = loadMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateLoadingMore(isLoadingMore: Boolean) {
|
||||
if (!designFeatureToggles.isRedesignEnabled) return
|
||||
_uiState.update { state ->
|
||||
if (state is TxHistoryItemsUM.Content && state.isLoadingMore != isLoadingMore) {
|
||||
state.copy(isLoadingMore = isLoadingMore)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBalanceHidden(isBalanceHidden: Boolean) {
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
_uiState.update { state -> state.copySealed(isBalanceHidden = isBalanceHidden) }
|
||||
} else {
|
||||
_legacyUiState.update { state -> state.copySealed(isBalanceHidden = isBalanceHidden) }
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePendingTransactions(
|
||||
pendingTxs: () -> ImmutableList<TransactionItemUM>,
|
||||
legacyPendingTxs: () -> ImmutableList<TransactionState>,
|
||||
) {
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
_uiState.update { state ->
|
||||
if (state is TxHistoryItemsUM.NotSupported) {
|
||||
state.copy(pendingTransactions = pendingTxs())
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_legacyUiState.update { state ->
|
||||
if (state is TxHistoryUM.NotSupported) {
|
||||
state.copy(pendingTransactions = legacyPendingTxs())
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package com.tangem.features.txhistory.utils
|
||||
|
||||
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.txhistory.models.PaginationWrapper
|
||||
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
internal class TxHistoryLegacyUiManager(
|
||||
private val state: MutableStateFlow<TxHistoryListState>,
|
||||
private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
|
||||
private val txHistoryUiActions: TxHistoryUiActions,
|
||||
) {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val items: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = state
|
||||
.filter { state ->
|
||||
state.status !is PaginationStatus.None &&
|
||||
state.status !is PaginationStatus.InitialLoading &&
|
||||
state.status !is PaginationStatus.InitialLoadingError
|
||||
}
|
||||
.mapLatest { state ->
|
||||
state.legacyUiBatches.asSequence()
|
||||
.flatMap { it.data }
|
||||
.toImmutableList()
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
fun createOrUpdateUiBatches(
|
||||
newCurrencyBatches: List<Batch<Int, PaginationWrapper<TxInfo>>>,
|
||||
shouldClearUiBatches: Boolean,
|
||||
): List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> {
|
||||
val currentUiBatches = state.value.legacyUiBatches
|
||||
val batches = if (shouldClearUiBatches) mutableListOf() else currentUiBatches.toMutableList()
|
||||
|
||||
for ((key, data) in newCurrencyBatches) {
|
||||
val existingBatchIndex = batches.indexOfFirst { it.key == key }
|
||||
if (existingBatchIndex == -1) {
|
||||
val items = generateUiItems(key, data)
|
||||
batches.add(Batch(key = key, data = items))
|
||||
} else if (currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data.items)) {
|
||||
val items = generateUiItems(key, data)
|
||||
batches[existingBatchIndex] = Batch(key = key, data = items)
|
||||
}
|
||||
}
|
||||
|
||||
return batches
|
||||
}
|
||||
|
||||
private fun generateUiItems(key: Int, data: PaginationWrapper<TxInfo>): List<TxHistoryUM.TxHistoryItemUM> {
|
||||
val items = mutableListOf<TxHistoryUM.TxHistoryItemUM>()
|
||||
|
||||
if (key == 0) {
|
||||
items.add(TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = txHistoryUiActions::openExplorer))
|
||||
}
|
||||
|
||||
if (data.items.isNotEmpty()) {
|
||||
val firstItem = data.items.first()
|
||||
val firstDate = firstItem.timestampInMillis.toDateFormatWithTodayYesterday()
|
||||
|
||||
items.add(
|
||||
TxHistoryUM.TxHistoryItemUM.GroupTitle(
|
||||
title = firstDate,
|
||||
itemKey = "$key-$firstDate",
|
||||
),
|
||||
)
|
||||
items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(firstItem)))
|
||||
|
||||
data.items.zipWithNext { current, next ->
|
||||
val currentDate = current.timestampInMillis.toDateFormatWithTodayYesterday()
|
||||
val nextDate = next.timestampInMillis.toDateFormatWithTodayYesterday()
|
||||
|
||||
if (currentDate != nextDate) {
|
||||
items.add(
|
||||
TxHistoryUM.TxHistoryItemUM.GroupTitle(
|
||||
title = nextDate,
|
||||
itemKey = "$key-$nextDate",
|
||||
),
|
||||
)
|
||||
}
|
||||
items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(next)))
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private fun List<TxHistoryUM.TxHistoryItemUM>.transactionItemsSizeNotEqual(txInfos: List<TxInfo>): Boolean {
|
||||
return this.filterIsInstance<TxHistoryUM.TxHistoryItemUM.Transaction>().size != txInfos.size
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +1,39 @@
|
|||
package com.tangem.features.txhistory.utils
|
||||
|
||||
import com.tangem.core.ui.DesignFeatureToggles
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
|
||||
import com.tangem.domain.txhistory.model.TxHistoryListConfig
|
||||
import com.tangem.domain.txhistory.models.PaginationWrapper
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter
|
||||
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.features.txhistory.model.TxHistoryLookupContext
|
||||
import com.tangem.features.txhistory.state.TxHistoryItemsSnapshot
|
||||
import com.tangem.pagination.BatchAction
|
||||
import com.tangem.pagination.BatchListState
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
private typealias TxHistoryBatchAction = BatchAction<Int, TxHistoryListConfig, Nothing>
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class TxHistoryListManager(
|
||||
private val repository: TxHistoryRepositoryV2,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val userWalletId: UserWalletId,
|
||||
private val currency: CryptoCurrency,
|
||||
txHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
|
||||
txHistoryUiActions: TxHistoryUiActions,
|
||||
private val designFeatureToggles: DesignFeatureToggles,
|
||||
private val txHistoryUiActions: TxHistoryUiActions,
|
||||
private val lookupDataFlow: Flow<TxHistoryLookupContext>,
|
||||
legacyTxHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
|
||||
) {
|
||||
|
||||
private val jobHolder = JobHolder()
|
||||
|
|
@ -37,13 +42,18 @@ internal class TxHistoryListManager(
|
|||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private val state: MutableStateFlow<TxHistoryListState> = MutableStateFlow(TxHistoryListState())
|
||||
private val uiManager = TxHistoryUiManager(
|
||||
private val uiManager = TxHistoryUiManager(state = state)
|
||||
private val legacyUiManager = TxHistoryLegacyUiManager(
|
||||
state = state,
|
||||
txHistoryItemConverter = txHistoryItemConverter,
|
||||
txHistoryItemConverter = legacyTxHistoryItemConverter,
|
||||
txHistoryUiActions = txHistoryUiActions,
|
||||
)
|
||||
|
||||
val uiItems: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = uiManager.items
|
||||
val uiItems: Flow<TxHistoryItemsSnapshot> = if (designFeatureToggles.isRedesignEnabled) {
|
||||
uiManager.items.map(TxHistoryItemsSnapshot::Items)
|
||||
} else {
|
||||
legacyUiManager.items.map(TxHistoryItemsSnapshot::LegacyItems)
|
||||
}
|
||||
val paginationStatus: Flow<PaginationStatus<*>> = state.map { it.status }.distinctUntilChanged()
|
||||
|
||||
suspend fun init() = coroutineScope {
|
||||
|
|
@ -55,11 +65,24 @@ internal class TxHistoryListManager(
|
|||
batchSize = 50,
|
||||
)
|
||||
|
||||
batchFlow.state
|
||||
.onEach { state -> updateState(state) }
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(scope = this)
|
||||
.saveIn(jobHolder)
|
||||
if (designFeatureToggles.isRedesignEnabled) {
|
||||
var previousLookup: TxHistoryLookupContext? = null
|
||||
combine(batchFlow.state, lookupDataFlow) { batchState, lookup -> batchState to lookup }
|
||||
.onEach { (batchState, lookup) ->
|
||||
val isLookupChanged = previousLookup != null && previousLookup != lookup
|
||||
previousLookup = lookup
|
||||
updateState(batchState, lookup, isLookupChanged)
|
||||
}
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(scope = this)
|
||||
.saveIn(jobHolder)
|
||||
} else {
|
||||
batchFlow.state
|
||||
.onEach { batchState -> updateState(batchState, lookupContext = null, isLookupChanged = false) }
|
||||
.flowOn(dispatchers.default)
|
||||
.launchIn(scope = this)
|
||||
.saveIn(jobHolder)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startLoading() {
|
||||
|
|
@ -86,16 +109,40 @@ internal class TxHistoryListManager(
|
|||
)
|
||||
}
|
||||
|
||||
private fun updateState(batchListState: BatchListState<Int, PaginationWrapper<TxInfo>>) {
|
||||
private fun updateState(
|
||||
batchListState: BatchListState<Int, PaginationWrapper<TxInfo>>,
|
||||
lookupContext: TxHistoryLookupContext?,
|
||||
isLookupChanged: Boolean,
|
||||
) {
|
||||
state.update { state ->
|
||||
val shouldClearUiBatches =
|
||||
state.status is PaginationStatus.InitialLoading && batchListState.status is PaginationStatus.Paginating
|
||||
val isInitialToPaginating = state.status is PaginationStatus.InitialLoading &&
|
||||
batchListState.status is PaginationStatus.Paginating
|
||||
val shouldClearUiBatches = isInitialToPaginating || isLookupChanged
|
||||
val isRedesignEnabled = designFeatureToggles.isRedesignEnabled
|
||||
state.copy(
|
||||
status = batchListState.status,
|
||||
uiBatches = uiManager.createOrUpdateUiBatches(
|
||||
newCurrencyBatches = batchListState.data,
|
||||
shouldClearUiBatches = shouldClearUiBatches,
|
||||
),
|
||||
uiBatches = if (isRedesignEnabled) {
|
||||
val converter = TxHistoryItemToTransactionItemUMConverter(
|
||||
currency = currency,
|
||||
txHistoryUiActions = txHistoryUiActions,
|
||||
lookupContext = lookupContext,
|
||||
)
|
||||
uiManager.createOrUpdateUiBatches(
|
||||
newCurrencyBatches = batchListState.data,
|
||||
shouldClearUiBatches = shouldClearUiBatches,
|
||||
converter = converter,
|
||||
)
|
||||
} else {
|
||||
state.uiBatches
|
||||
},
|
||||
legacyUiBatches = if (isRedesignEnabled) {
|
||||
state.legacyUiBatches
|
||||
} else {
|
||||
legacyUiManager.createOrUpdateUiBatches(
|
||||
newCurrencyBatches = batchListState.data,
|
||||
shouldClearUiBatches = shouldClearUiBatches,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package com.tangem.features.txhistory.utils
|
||||
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
|
||||
data class TxHistoryListState(
|
||||
val status: PaginationStatus<*> = PaginationStatus.None,
|
||||
val uiBatches: List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> = emptyList(),
|
||||
val uiBatches: List<Batch<Int, List<TxHistoryItemsUM.TxHistoryItemUM>>> = emptyList(),
|
||||
val legacyUiBatches: List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> = emptyList(),
|
||||
)
|
||||
|
|
@ -3,30 +3,22 @@ package com.tangem.features.txhistory.utils
|
|||
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.txhistory.models.PaginationWrapper
|
||||
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter
|
||||
import com.tangem.features.txhistory.entity.TxHistoryUM
|
||||
import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter
|
||||
import com.tangem.features.txhistory.entity.TxHistoryItemsUM
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import java.util.UUID
|
||||
|
||||
internal class TxHistoryUiManager(
|
||||
private val state: MutableStateFlow<TxHistoryListState>,
|
||||
private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter,
|
||||
private val txHistoryUiActions: TxHistoryUiActions,
|
||||
) {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val items: Flow<ImmutableList<TxHistoryUM.TxHistoryItemUM>> = state
|
||||
// filter initial states, since we dont emit loading items as UI items
|
||||
.filter { state ->
|
||||
state.status !is PaginationStatus.None &&
|
||||
state.status !is PaginationStatus.InitialLoading &&
|
||||
state.status !is PaginationStatus.InitialLoadingError
|
||||
}
|
||||
val items: Flow<ImmutableList<TxHistoryItemsUM.TxHistoryItemUM>> = state
|
||||
.filter { it.hasContent }
|
||||
.mapLatest { state ->
|
||||
state.uiBatches.asSequence()
|
||||
.flatMap { it.data }
|
||||
|
|
@ -37,79 +29,69 @@ internal class TxHistoryUiManager(
|
|||
fun createOrUpdateUiBatches(
|
||||
newCurrencyBatches: List<Batch<Int, PaginationWrapper<TxInfo>>>,
|
||||
shouldClearUiBatches: Boolean,
|
||||
): List<Batch<Int, List<TxHistoryUM.TxHistoryItemUM>>> {
|
||||
converter: TxHistoryItemToTransactionItemUMConverter,
|
||||
): List<Batch<Int, List<TxHistoryItemsUM.TxHistoryItemUM>>> {
|
||||
val currentUiBatches = state.value.uiBatches
|
||||
val batches = if (shouldClearUiBatches) mutableListOf() else currentUiBatches.toMutableList()
|
||||
|
||||
for ((key, data) in newCurrencyBatches) {
|
||||
// Find if batch with same key exists
|
||||
val existingBatchIndex = batches.indexOfFirst { it.key == key }
|
||||
val shouldUpdateExisting = existingBatchIndex != -1 &&
|
||||
currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data.items)
|
||||
|
||||
// Case 1: Update existing batch if sizes differ
|
||||
if (shouldUpdateExisting) {
|
||||
val items = generateUiItems(key, data)
|
||||
if (existingBatchIndex == -1) {
|
||||
val items = generateUiItems(key, data, converter)
|
||||
batches.add(Batch(key = key, data = items))
|
||||
} else if (currentUiBatches[existingBatchIndex].data.transactionItemsSizeNotEqual(data.items)) {
|
||||
val items = generateUiItems(key, data, converter)
|
||||
batches[existingBatchIndex] = Batch(key = key, data = items)
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 2: Skip if batch exists and has same size
|
||||
if (existingBatchIndex != -1) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 3: Create new batch
|
||||
val items = generateUiItems(key, data)
|
||||
batches.add(Batch(key = key, data = items))
|
||||
}
|
||||
|
||||
return batches
|
||||
}
|
||||
|
||||
private fun generateUiItems(key: Int, data: PaginationWrapper<TxInfo>): List<TxHistoryUM.TxHistoryItemUM> {
|
||||
val items = mutableListOf<TxHistoryUM.TxHistoryItemUM>()
|
||||
private fun generateUiItems(
|
||||
key: Int,
|
||||
data: PaginationWrapper<TxInfo>,
|
||||
converter: TxHistoryItemToTransactionItemUMConverter,
|
||||
): List<TxHistoryItemsUM.TxHistoryItemUM> {
|
||||
val items = mutableListOf<TxHistoryItemsUM.TxHistoryItemUM>()
|
||||
|
||||
// Add title for the first batch
|
||||
if (key == 0) {
|
||||
items.add(TxHistoryUM.TxHistoryItemUM.Title(onExploreClick = txHistoryUiActions::openExplorer))
|
||||
}
|
||||
|
||||
// Process batch items only if there are any
|
||||
if (data.items.isNotEmpty()) {
|
||||
// Add first item with its group title
|
||||
val firstItem = data.items.first()
|
||||
val firstDate = firstItem.timestampInMillis.toDateFormatWithTodayYesterday()
|
||||
|
||||
items.add(
|
||||
TxHistoryUM.TxHistoryItemUM.GroupTitle(
|
||||
TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(
|
||||
title = firstDate,
|
||||
itemKey = UUID.randomUUID().toString(),
|
||||
itemKey = "$key-$firstDate",
|
||||
),
|
||||
)
|
||||
items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(firstItem)))
|
||||
items.add(TxHistoryItemsUM.TxHistoryItemUM.Transaction(converter.convert(firstItem)))
|
||||
|
||||
// Process remaining items with date separators when needed
|
||||
data.items.zipWithNext { current, next ->
|
||||
val currentDate = current.timestampInMillis.toDateFormatWithTodayYesterday()
|
||||
val nextDate = next.timestampInMillis.toDateFormatWithTodayYesterday()
|
||||
|
||||
if (currentDate != nextDate) {
|
||||
items.add(
|
||||
TxHistoryUM.TxHistoryItemUM.GroupTitle(
|
||||
TxHistoryItemsUM.TxHistoryItemUM.GroupTitle(
|
||||
title = nextDate,
|
||||
itemKey = UUID.randomUUID().toString(),
|
||||
itemKey = "$key-$nextDate",
|
||||
),
|
||||
)
|
||||
}
|
||||
items.add(TxHistoryUM.TxHistoryItemUM.Transaction(txHistoryItemConverter.convert(next)))
|
||||
items.add(TxHistoryItemsUM.TxHistoryItemUM.Transaction(converter.convert(next)))
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private fun List<TxHistoryUM.TxHistoryItemUM>.transactionItemsSizeNotEqual(txInfos: List<TxInfo>): Boolean {
|
||||
return this.filterIsInstance<TxHistoryUM.TxHistoryItemUM.Transaction>().size != txInfos.size
|
||||
private fun List<TxHistoryItemsUM.TxHistoryItemUM>.transactionItemsSizeNotEqual(txInfos: List<TxInfo>): Boolean {
|
||||
return this.filterIsInstance<TxHistoryItemsUM.TxHistoryItemUM.Transaction>().size != txInfos.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val TxHistoryListState.hasContent: Boolean
|
||||
get() = status !is PaginationStatus.None &&
|
||||
status !is PaginationStatus.InitialLoading &&
|
||||
status !is PaginationStatus.InitialLoadingError
|
||||
|
|
@ -0,0 +1,767 @@
|
|||
package com.tangem.features.txhistory.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM
|
||||
import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle
|
||||
import com.tangem.core.ui.ds.image.DeviceIconUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.account.Account.CryptoPortfolio.Companion.createMainAccount
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.network.TxInfo.TransactionType
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.txhistory.impl.R
|
||||
import com.tangem.features.txhistory.model.TxHistoryLookupContext
|
||||
import com.tangem.features.txhistory.model.WalletInfo
|
||||
import com.tangem.features.txhistory.utils.TxHistoryUiActions
|
||||
import com.tangem.utils.StringsSigns
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class TxHistoryItemToTransactionItemUMConverterTest {
|
||||
|
||||
private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true)
|
||||
private val coin: CryptoCurrency.Coin = createCoin(symbol = "ETH", decimals = 18)
|
||||
private val token: CryptoCurrency.Token = createToken(symbol = "USDT", decimals = 6)
|
||||
|
||||
private val coinConverter
|
||||
get() = TxHistoryItemToTransactionItemUMConverter(
|
||||
currency = coin,
|
||||
txHistoryUiActions = txHistoryUiActions,
|
||||
)
|
||||
|
||||
private val tokenConverter
|
||||
get() = TxHistoryItemToTransactionItemUMConverter(
|
||||
currency = token,
|
||||
txHistoryUiActions = txHistoryUiActions,
|
||||
)
|
||||
|
||||
// region Pill dispatch routing
|
||||
|
||||
@Test
|
||||
fun `GIVEN Pill TransactionType WHEN convert THEN result is Pill with expected kind`() {
|
||||
val cases = listOf(
|
||||
TransactionType.Approve to TransactionItemUM.PillKind.APPROVE,
|
||||
TransactionType.Staking.Stake to TransactionItemUM.PillKind.STAKING,
|
||||
TransactionType.Staking.Unstake to TransactionItemUM.PillKind.STAKING,
|
||||
TransactionType.Staking.Restake to TransactionItemUM.PillKind.STAKING,
|
||||
TransactionType.Staking.Vote(validatorAddress = "0xv") to TransactionItemUM.PillKind.STAKING,
|
||||
TransactionType.Staking.Withdraw to TransactionItemUM.PillKind.STAKING,
|
||||
TransactionType.YieldSupply.Enter(address = USER_ADDRESS) to TransactionItemUM.PillKind.YIELD_MODE,
|
||||
TransactionType.YieldSupply.Exit(address = USER_ADDRESS) to TransactionItemUM.PillKind.YIELD_MODE,
|
||||
)
|
||||
|
||||
cases.forEach { (type, expectedKind) ->
|
||||
val tx = txInfo(type = type)
|
||||
val result = coinConverter.convert(tx)
|
||||
assertThat(result).isInstanceOf(TransactionItemUM.Pill::class.java)
|
||||
assertThat((result as TransactionItemUM.Pill).kind).isEqualTo(expectedKind)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Content — basic types
|
||||
|
||||
@Test
|
||||
fun `GIVEN Operation WHEN convert THEN Content with type name as title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint NFT"),
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(TextReference.Str("Mint NFT"))
|
||||
assertThat(result.iconRes).isEqualTo(R.drawable.ic_arrow_down_24)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Swap confirmed WHEN convert THEN Content with swapped title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Swap,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_swapped))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Swap unconfirmed WHEN convert THEN Content with swapping title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Swap,
|
||||
status = TxInfo.TransactionStatus.Unconfirmed,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_swapping))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Swap failed WHEN convert THEN Content with composed failed title and close icon`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Swap,
|
||||
status = TxInfo.TransactionStatus.Failed,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(
|
||||
resRef(R.string.common_action_failed, listOf(resRef(R.string.common_swapping))),
|
||||
)
|
||||
assertThat(result.iconRes).isEqualTo(R.drawable.ic_close_24)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN UnknownOperation WHEN convert THEN Content with operation title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.UnknownOperation,
|
||||
interactionAddressType = null,
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.transaction_history_operation))
|
||||
assertThat(result.subtitle).isEqualTo(ContentSubtitle.Plain(TextReference.EMPTY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN GaslessFee WHEN convert THEN Content with gasless fee title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.GaslessFee,
|
||||
interactionAddressType = null,
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.gasless_transaction_fee))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN ClaimRewards confirmed WHEN convert THEN Content with reward title and no amount sign`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Staking.ClaimRewards,
|
||||
isOutgoing = false,
|
||||
amount = BigDecimal("2.5"),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.transaction_history_staking_reward))
|
||||
assertThat(result.subtitle).isEqualTo(
|
||||
ContentSubtitle.Plain(resRef(R.string.transaction_history_earned_from_stake)),
|
||||
)
|
||||
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
|
||||
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN ClaimRewards unconfirmed WHEN convert THEN Content with claiming title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Staking.ClaimRewards,
|
||||
status = TxInfo.TransactionStatus.Unconfirmed,
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.transaction_history_claiming_reward))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Content — Transfer
|
||||
|
||||
@Test
|
||||
fun `GIVEN outgoing Transfer confirmed to external address WHEN convert THEN sent title and ExternalAddress subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
|
||||
assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.OUTGOING)
|
||||
assertThat(result.iconRes).isEqualTo(R.drawable.ic_arrow_up_24)
|
||||
val subtitle = result.subtitle as ContentSubtitle.ExternalAddress
|
||||
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO)
|
||||
assertThat(subtitle.rawAddress).isEqualTo(USER_ADDRESS)
|
||||
assertThat(subtitle.briefAddress).isEqualTo(USER_ADDRESS_BRIEF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN outgoing Transfer unconfirmed WHEN convert THEN sending title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
status = TxInfo.TransactionStatus.Unconfirmed,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_sending))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN outgoing Transfer failed WHEN convert THEN composed failed title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
status = TxInfo.TransactionStatus.Failed,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(
|
||||
resRef(R.string.common_action_failed, listOf(resRef(R.string.common_sending))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN incoming Transfer confirmed WHEN convert THEN received title and FROM subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = false,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_received))
|
||||
assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.INCOMING)
|
||||
assertThat(result.iconRes).isEqualTo(R.drawable.ic_arrow_down_24)
|
||||
val subtitle = result.subtitle as ContentSubtitle.ExternalAddress
|
||||
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN incoming Transfer unconfirmed WHEN convert THEN receiving title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = false,
|
||||
status = TxInfo.TransactionStatus.Unconfirmed,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_receiving))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Transfer with own account in accounts mode WHEN convert THEN OwnAccount subtitle and transferred title`() {
|
||||
val ownAccount = createMainAccount(UserWalletId(stringValue = "00"))
|
||||
val converter = TxHistoryItemToTransactionItemUMConverter(
|
||||
currency = coin,
|
||||
txHistoryUiActions = txHistoryUiActions,
|
||||
lookupContext = TxHistoryLookupContext(
|
||||
ownAccountByAddress = mapOf(USER_ADDRESS to ownAccount),
|
||||
isAccountsModeEnabled = true,
|
||||
walletInfoById = emptyMap(),
|
||||
),
|
||||
)
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = converter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_transferred))
|
||||
val subtitle = result.subtitle as ContentSubtitle.OwnAccount
|
||||
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO)
|
||||
assertThat(subtitle.iconResId).isNotEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Transfer with own account in wallets mode WHEN convert THEN OwnWallet subtitle`() {
|
||||
val userWalletId = UserWalletId(stringValue = "01")
|
||||
val ownAccount = createMainAccount(userWalletId)
|
||||
val walletInfo = WalletInfo(name = "Main wallet", deviceIconUM = DeviceIconUM.Mobile)
|
||||
val converter = TxHistoryItemToTransactionItemUMConverter(
|
||||
currency = coin,
|
||||
txHistoryUiActions = txHistoryUiActions,
|
||||
lookupContext = TxHistoryLookupContext(
|
||||
ownAccountByAddress = mapOf(USER_ADDRESS to ownAccount),
|
||||
isAccountsModeEnabled = false,
|
||||
walletInfoById = mapOf(userWalletId to walletInfo),
|
||||
),
|
||||
)
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = false,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = converter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_transferred))
|
||||
val subtitle = result.subtitle as ContentSubtitle.OwnWallet
|
||||
assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM)
|
||||
assertThat(subtitle.walletName).isEqualTo("Main wallet")
|
||||
assertThat(subtitle.deviceIconUM).isEqualTo(DeviceIconUM.Mobile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Transfer with own account but missing wallet info in wallets mode WHEN convert THEN ExternalAddress subtitle`() {
|
||||
val ownAccount = createMainAccount(UserWalletId(stringValue = "02"))
|
||||
val converter = TxHistoryItemToTransactionItemUMConverter(
|
||||
currency = coin,
|
||||
txHistoryUiActions = txHistoryUiActions,
|
||||
lookupContext = TxHistoryLookupContext(
|
||||
ownAccountByAddress = mapOf(USER_ADDRESS to ownAccount),
|
||||
isAccountsModeEnabled = false,
|
||||
walletInfoById = emptyMap(),
|
||||
),
|
||||
)
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = converter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
|
||||
assertThat(result.subtitle).isInstanceOf(ContentSubtitle.ExternalAddress::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Transfer with non-User interaction WHEN convert THEN Plain subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.subtitle).isInstanceOf(ContentSubtitle.Plain::class.java)
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Content — YieldSupply
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Topup WHEN convert THEN topup title`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.Topup)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_topup))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Send Coin not withdraw and incoming WHEN convert THEN transfer title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = false),
|
||||
isOutgoing = false,
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.common_transfer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Send Coin withdraw WHEN convert THEN withdraw title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = true),
|
||||
isOutgoing = false,
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_withdraw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Send outgoing WHEN convert THEN withdraw title`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = false),
|
||||
isOutgoing = true,
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_withdraw))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Send Token incoming WHEN convert THEN amount and symbol hidden`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = false),
|
||||
isOutgoing = false,
|
||||
)
|
||||
|
||||
val result = tokenConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.amount).isEmpty()
|
||||
assertThat(result.currencySymbol).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Send Token outgoing WHEN convert THEN amount and symbol shown`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = true),
|
||||
isOutgoing = true,
|
||||
)
|
||||
|
||||
val result = tokenConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.amount).isNotEmpty()
|
||||
assertThat(result.currencySymbol).isEqualTo("USDT")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply DeployContract WHEN convert THEN deploy title and doc icon`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.DeployContract(address = USER_ADDRESS))
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_deploy_contract))
|
||||
assertThat(result.iconRes).isEqualTo(R.drawable.ic_doc_24)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply InitializeToken WHEN convert THEN initialize title and gear icon`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.InitializeToken(address = USER_ADDRESS))
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_initialize))
|
||||
assertThat(result.iconRes).isEqualTo(R.drawable.ic_gear_24)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply ReactivateToken WHEN convert THEN reactivate title and refresh icon`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.ReactivateToken(address = USER_ADDRESS))
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.title).isEqualTo(resRef(R.string.yield_module_transaction_reactivate))
|
||||
assertThat(result.iconRes).isEqualTo(R.drawable.ic_refresh_24)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Topup Token WHEN convert THEN amount-formatted topup subtitle`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.Topup, amount = BigDecimal("3.0"))
|
||||
|
||||
val result = tokenConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
||||
val res = subtitle.text as TextReference.Res
|
||||
assertThat(res.id).isEqualTo(R.string.yield_module_transaction_topup_subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Send Token withdraw incoming WHEN convert THEN exit subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.YieldSupply.Send(address = USER_ADDRESS, isYieldSupplyWithdraw = true),
|
||||
isOutgoing = false,
|
||||
)
|
||||
|
||||
val result = tokenConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
||||
val res = subtitle.text as TextReference.Res
|
||||
assertThat(res.id).isEqualTo(R.string.yield_module_transaction_exit_subtitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldSupply Topup Coin WHEN convert THEN address-based subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.YieldSupply.Topup,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
isOutgoing = true,
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
||||
val res = subtitle.text as TextReference.Res
|
||||
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_for_address)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Amount formatting
|
||||
|
||||
@Test
|
||||
fun `GIVEN outgoing confirmed WHEN convert THEN amount has minus prefix`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
isOutgoing = true,
|
||||
amount = BigDecimal("1.5"),
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN incoming confirmed WHEN convert THEN amount has plus prefix`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
isOutgoing = false,
|
||||
amount = BigDecimal("1.5"),
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN failed Operation WHEN convert THEN amount has no sign prefix`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
isOutgoing = true,
|
||||
status = TxInfo.TransactionStatus.Failed,
|
||||
amount = BigDecimal("1.5"),
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
|
||||
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero amount Operation WHEN convert THEN amount has no sign prefix`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
isOutgoing = true,
|
||||
amount = BigDecimal.ZERO,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse()
|
||||
assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Address subtitle resolution
|
||||
|
||||
@Test
|
||||
fun `GIVEN Operation with Contract interaction WHEN convert THEN contract address subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
||||
val res = subtitle.text as TextReference.Res
|
||||
assertThat(res.id).isEqualTo(R.string.transaction_history_contract_address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Operation with Multiple interaction outgoing WHEN convert THEN to-address subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
isOutgoing = true,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Multiple(
|
||||
addresses = listOf(USER_ADDRESS, "0xother"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
||||
val res = subtitle.text as TextReference.Res
|
||||
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_to_address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Operation with Multiple interaction incoming WHEN convert THEN from-address subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
isOutgoing = false,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Multiple(
|
||||
addresses = listOf(USER_ADDRESS),
|
||||
),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
||||
val res = subtitle.text as TextReference.Res
|
||||
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_from_address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Operation with Validator interaction WHEN convert THEN validator subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Validator(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
||||
val res = subtitle.text as TextReference.Res
|
||||
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_validator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Operation with null interaction WHEN convert THEN empty subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
interactionAddressType = null,
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.subtitle).isEqualTo(ContentSubtitle.Plain(TextReference.EMPTY))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Misc
|
||||
|
||||
@Test
|
||||
fun `GIVEN failed Transfer WHEN convert THEN icon overridden to close`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Transfer,
|
||||
isOutgoing = true,
|
||||
status = TxInfo.TransactionStatus.Failed,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.iconRes).isEqualTo(R.drawable.ic_close_24)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN any Content WHEN onClick invoked THEN openTxInExplorer called with txHash`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
result.onClick()
|
||||
|
||||
verify { txHistoryUiActions.openTxInExplorer(TX_HASH) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN tx WHEN convert THEN txHash and timestamp propagated`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Operation(name = "Mint"),
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||
|
||||
assertThat(result.txHash).isEqualTo(TX_HASH)
|
||||
assertThat(result.timestamp).isEqualTo(TIMESTAMP)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helpers
|
||||
|
||||
private fun txInfo(
|
||||
type: TransactionType,
|
||||
status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed,
|
||||
isOutgoing: Boolean = false,
|
||||
amount: BigDecimal = BigDecimal.ONE,
|
||||
interactionAddressType: TxInfo.InteractionAddressType? = null,
|
||||
): TxInfo = TxInfo(
|
||||
txHash = TX_HASH,
|
||||
timestampInMillis = TIMESTAMP,
|
||||
isOutgoing = isOutgoing,
|
||||
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
|
||||
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
|
||||
interactionAddressType = interactionAddressType,
|
||||
status = status,
|
||||
type = type,
|
||||
amount = amount,
|
||||
)
|
||||
|
||||
private fun resRef(id: Int): TextReference = TextReference.Res(id = id)
|
||||
|
||||
private fun resRef(id: Int, args: List<Any>): TextReference = TextReference.Res(
|
||||
id = id,
|
||||
formatArgs = com.tangem.core.ui.extensions.WrappedList(args),
|
||||
)
|
||||
|
||||
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawId = "ethereum"),
|
||||
),
|
||||
network = createNetwork(symbol = symbol, canHandleTokens = true),
|
||||
name = "Ethereum",
|
||||
symbol = symbol,
|
||||
decimals = decimals,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
|
||||
private fun createToken(symbol: String, decimals: Int): CryptoCurrency.Token = CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.ContractAddress(contractAddress = TOKEN_CONTRACT),
|
||||
),
|
||||
network = createNetwork(symbol = "ETH", canHandleTokens = true),
|
||||
name = "Tether USD",
|
||||
symbol = symbol,
|
||||
decimals = decimals,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = TOKEN_CONTRACT,
|
||||
)
|
||||
|
||||
private fun createNetwork(symbol: String, canHandleTokens: Boolean): Network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None),
|
||||
name = "Ethereum",
|
||||
currencySymbol = symbol,
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.ERC20,
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = canHandleTokens,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TX_HASH = "0xtxhash"
|
||||
const val TIMESTAMP = 1_700_000_000_000L
|
||||
const val USER_ADDRESS = "0x1234567890abcdef1234"
|
||||
const val USER_ADDRESS_BRIEF = "0x1234...1234"
|
||||
const val TOKEN_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
package com.tangem.features.txhistory.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.extensions.TextReference
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.network.TxInfo.TransactionType
|
||||
import com.tangem.features.txhistory.converter.TxHistoryStatusPillConverter.Input
|
||||
import com.tangem.features.txhistory.impl.R
|
||||
import com.tangem.features.txhistory.utils.TxHistoryUiActions
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class TxHistoryStatusPillConverterTest {
|
||||
|
||||
private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true)
|
||||
private val coin = createCoin(symbol = "ETH", decimals = 18)
|
||||
private val converter = TxHistoryStatusPillConverter(coin, txHistoryUiActions)
|
||||
|
||||
// region Approve
|
||||
|
||||
@Test
|
||||
fun `GIVEN Approve uiStatus Confirmed with User address WHEN convert THEN approved label and address subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Approve,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, ApproveSpec))
|
||||
|
||||
assertThat(result.kind).isEqualTo(TransactionItemUM.PillKind.APPROVE)
|
||||
assertThat(result.status).isEqualTo(Status.Confirmed)
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.common_approved))
|
||||
assertThat(result.amount).isNotNull()
|
||||
assertThat(result.currencySymbol).isEqualTo("ETH")
|
||||
val subtitle = result.subtitle as TransactionItemUM.PillSubtitle.Address
|
||||
assertThat(subtitle.rawAddress).isEqualTo(USER_ADDRESS)
|
||||
assertThat(subtitle.briefAddress).isEqualTo(USER_ADDRESS_BRIEF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Approve uiStatus Unconfirmed WHEN convert THEN approving label`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Approve,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Unconfirmed, ApproveSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.common_approving))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Approve uiStatus Failed WHEN convert THEN non-composed approving label and no subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Approve,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Failed, ApproveSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.common_approving))
|
||||
assertThat(result.subtitle).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Approve uiStatus Confirmed without User interaction address WHEN convert THEN no subtitle`() {
|
||||
val tx = txInfo(
|
||||
type = TransactionType.Approve,
|
||||
interactionAddressType = TxInfo.InteractionAddressType.Contract(USER_ADDRESS),
|
||||
)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, ApproveSpec))
|
||||
|
||||
assertThat(result.subtitle).isNull()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Staking
|
||||
|
||||
@Test
|
||||
fun `GIVEN Stake uiStatus Confirmed WHEN convert THEN staked label and amount`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Stake, amount = BigDecimal("1.5"))
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, StakeSpec))
|
||||
|
||||
assertThat(result.kind).isEqualTo(TransactionItemUM.PillKind.STAKING)
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.common_staked))
|
||||
assertThat(result.amount).isNotNull()
|
||||
assertThat(result.currencySymbol).isEqualTo("ETH")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Stake uiStatus Failed WHEN convert THEN composed failed label and no amount`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Stake)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Failed, StakeSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(
|
||||
resRef(R.string.common_action_failed, listOf(resRef(R.string.common_staking))),
|
||||
)
|
||||
assertThat(result.amount).isNull()
|
||||
assertThat(result.currencySymbol).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Unstake uiStatus Confirmed WHEN convert THEN unstaked label`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Unstake)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, UnstakeSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.staking_unstaked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Restake uiStatus Confirmed WHEN convert THEN restaked label`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Restake)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, RestakeSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.transaction_history_rewards_restaked))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Vote uiStatus Confirmed WHEN convert THEN vote label and no amount`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Vote(validatorAddress = "0xv"))
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, VoteSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.staking_vote))
|
||||
assertThat(result.amount).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Vote uiStatus Failed WHEN convert THEN composed failed vote label`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Vote(validatorAddress = "0xv"))
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Failed, VoteSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(
|
||||
resRef(R.string.common_action_failed, listOf(resRef(R.string.staking_vote))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Withdraw uiStatus Confirmed WHEN convert THEN withdraw label and no amount`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Withdraw)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, WithdrawSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.staking_withdraw))
|
||||
assertThat(result.amount).isNull()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region YieldSupply
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldEnter uiStatus Confirmed WHEN convert THEN enter label and no amount`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.Enter(address = USER_ADDRESS))
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, YieldEnterSpec))
|
||||
|
||||
assertThat(result.kind).isEqualTo(TransactionItemUM.PillKind.YIELD_MODE)
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.yield_module_transaction_enter))
|
||||
assertThat(result.amount).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldEnter uiStatus Failed WHEN convert THEN composed failed yield mode label`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.Enter(address = USER_ADDRESS))
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Failed, YieldEnterSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(
|
||||
resRef(R.string.common_action_failed, listOf(resRef(R.string.common_yield_mode))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldExit uiStatus Confirmed WHEN convert THEN exit label`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.Exit(address = USER_ADDRESS))
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, YieldExitSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(resRef(R.string.yield_module_transaction_exit))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN YieldExit uiStatus Failed WHEN convert THEN composed failed label`() {
|
||||
val tx = txInfo(type = TransactionType.YieldSupply.Exit(address = USER_ADDRESS))
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Failed, YieldExitSpec))
|
||||
|
||||
assertThat(result.label).isEqualTo(
|
||||
resRef(
|
||||
R.string.common_action_failed,
|
||||
listOf(resRef(R.string.transaction_history_disabling_yield_mode)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Misc
|
||||
|
||||
@Test
|
||||
fun `GIVEN any Pill WHEN onClick invoked THEN openTxInExplorer called with txHash`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Stake)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, StakeSpec))
|
||||
result.onClick()
|
||||
|
||||
verify { txHistoryUiActions.openTxInExplorer(TX_HASH) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN tx WHEN convert THEN txHash and timestamp propagated`() {
|
||||
val tx = txInfo(type = TransactionType.Staking.Stake)
|
||||
|
||||
val result = converter.convert(Input(tx, Status.Confirmed, StakeSpec))
|
||||
|
||||
assertThat(result.txHash).isEqualTo(TX_HASH)
|
||||
assertThat(result.timestamp).isEqualTo(TIMESTAMP)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Helpers
|
||||
|
||||
private fun txInfo(
|
||||
type: TransactionType,
|
||||
amount: BigDecimal = BigDecimal.ONE,
|
||||
interactionAddressType: TxInfo.InteractionAddressType? = null,
|
||||
): TxInfo = TxInfo(
|
||||
txHash = TX_HASH,
|
||||
timestampInMillis = TIMESTAMP,
|
||||
isOutgoing = false,
|
||||
destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)),
|
||||
sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS),
|
||||
interactionAddressType = interactionAddressType,
|
||||
status = TxInfo.TransactionStatus.Confirmed,
|
||||
type = type,
|
||||
amount = amount,
|
||||
)
|
||||
|
||||
private fun resRef(id: Int): TextReference = TextReference.Res(id = id)
|
||||
|
||||
private fun resRef(id: Int, args: List<Any>): TextReference = TextReference.Res(
|
||||
id = id,
|
||||
formatArgs = com.tangem.core.ui.extensions.WrappedList(args),
|
||||
)
|
||||
|
||||
private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID(rawId = "ethereum"),
|
||||
),
|
||||
network = createNetwork(symbol = symbol),
|
||||
name = "Ethereum",
|
||||
symbol = symbol,
|
||||
decimals = decimals,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
|
||||
private fun createNetwork(symbol: String): Network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None),
|
||||
name = "Ethereum",
|
||||
currencySymbol = symbol,
|
||||
derivationPath = Network.DerivationPath.None,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.ERC20,
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TX_HASH = "0xtxhash"
|
||||
const val TIMESTAMP = 1_700_000_000_000L
|
||||
const val USER_ADDRESS = "0x1234567890abcdef1234"
|
||||
const val USER_ADDRESS_BRIEF = "0x1234...1234"
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue