diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/InlineImageSubtitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/InlineImageSubtitle.kt new file mode 100644 index 0000000000..3fd38082ea --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/InlineImageSubtitle.kt @@ -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, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt index 3b3d6047d0..0dcc40a3b0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/Transaction.kt @@ -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 diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt new file mode 100644 index 0000000000..4d25a9602d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt @@ -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) { + 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 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionStatusPill.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionStatusPill.kt new file mode 100644 index 0000000000..204873c322 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionStatusPill.kt @@ -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) { + 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 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryDateHeader.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryDateHeader.kt new file mode 100644 index 0000000000..8788c589a2 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TxHistoryDateHeader.kt @@ -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") + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt new file mode 100644 index 0000000000..2debebbbee --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt @@ -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: ". + * 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: ". + */ + 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: ". + */ + 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 +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 03668be768..663585428d 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -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 = MutableStateFlow( + override val legacyTxHistoryState: StateFlow = MutableStateFlow( value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}), ) + override val txHistoryState: StateFlow = 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 }, ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index fed9925378..69dd4303c0 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -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 = MutableStateFlow( + override val legacyTxHistoryState: StateFlow = MutableStateFlow( value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}), ) + override val txHistoryState: StateFlow = 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 diff --git a/features/txhistory/api/build.gradle.kts b/features/txhistory/api/build.gradle.kts index 33ac80bd64..b26a92aadc 100644 --- a/features/txhistory/api/build.gradle.kts +++ b/features/txhistory/api/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { /** Compose */ implementation(deps.compose.runtime) implementation(deps.compose.foundation) + implementation(deps.compose.material3) implementation(deps.compose.ui.tooling) /** Other */ diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt index a18865d1d5..581f4ac2e2 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt @@ -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 + val legacyTxHistoryState: StateFlow + + val txHistoryState: StateFlow 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, diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryItemsUM.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryItemsUM.kt new file mode 100644 index 0000000000..d14e9fbd3c --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryItemsUM.kt @@ -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, + 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, + 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 + } +} \ No newline at end of file diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt index 56637babd7..1c7f55bbac 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt @@ -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 = {}, ), diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 3ea746b243..6f7a097ec9 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.txhistory.impl" } +tasks.withType().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) } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt index 7e1db0ee54..a00e7ed8e9 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt @@ -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 + override val legacyTxHistoryState: StateFlow + get() = model.legacyUiState + + override val txHistoryState: StateFlow 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) } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt new file mode 100644 index 0000000000..30d4fa4243 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverter.kt @@ -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 { + + 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 \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverter.kt new file mode 100644 index 0000000000..75d7806ebd --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverter.kt @@ -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 { + + 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) + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt new file mode 100644 index 0000000000..32f290cf21 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt @@ -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, + val isAccountsModeEnabled: Boolean, + val walletInfoById: Map, +) + +internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM) \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index 7d89a3a061..babda57ca1 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -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 = 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 = - MutableStateFlow(TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = ::openExplorer)) - val uiState: StateFlow = _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): Map { + val networkRawId = params.currency.network.id.rawId + val map = mutableMapOf() + 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) { - _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, 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) { + private fun handlePendingTxsChanges( + maybeCurrencyStatus: Option, + 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() }, + ) } } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryItemsSnapshot.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryItemsSnapshot.kt new file mode 100644 index 0000000000..69c420f0a1 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryItemsSnapshot.kt @@ -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) : TxHistoryItemsSnapshot + + data class LegacyItems(val items: ImmutableList) : TxHistoryItemsSnapshot +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryStateController.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryStateController.kt new file mode 100644 index 0000000000..ac79d77a9a --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/state/TxHistoryStateController.kt @@ -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 = + MutableStateFlow(TxHistoryUM.Loading(isBalanceHidden = true, onExploreClick = {})) + val legacyUiState: StateFlow = _legacyUiState + + private val _uiState: MutableStateFlow = + MutableStateFlow(TxHistoryItemsUM.Loading(isBalanceHidden = true, onExploreClick = {})) + val uiState: StateFlow = _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, + legacyPendingTxs: () -> ImmutableList, + ) { + 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 + } + } + } + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryLegacyUiManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryLegacyUiManager.kt new file mode 100644 index 0000000000..8c5c5fd107 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryLegacyUiManager.kt @@ -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, + private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter, + private val txHistoryUiActions: TxHistoryUiActions, +) { + + @OptIn(ExperimentalCoroutinesApi::class) + val items: Flow> = 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>>, + shouldClearUiBatches: Boolean, + ): List>> { + 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): List { + val items = mutableListOf() + + 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.transactionItemsSizeNotEqual(txInfos: List): Boolean { + return this.filterIsInstance().size != txInfos.size + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt index 485c064e0a..788a17198d 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt @@ -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 +@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, + legacyTxHistoryItemConverter: TxHistoryItemToTransactionStateConverter, ) { private val jobHolder = JobHolder() @@ -37,13 +42,18 @@ internal class TxHistoryListManager( onBufferOverflow = BufferOverflow.DROP_OLDEST, ) private val state: MutableStateFlow = 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> = uiManager.items + val uiItems: Flow = if (designFeatureToggles.isRedesignEnabled) { + uiManager.items.map(TxHistoryItemsSnapshot::Items) + } else { + legacyUiManager.items.map(TxHistoryItemsSnapshot::LegacyItems) + } val paginationStatus: Flow> = 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>) { + private fun updateState( + batchListState: BatchListState>, + 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, + ) + }, ) } } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt index e27adfb4ce..6c402e865a 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt @@ -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>> = emptyList(), + val uiBatches: List>> = emptyList(), + val legacyUiBatches: List>> = emptyList(), ) \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt index 84a2b5be73..f929e1e03e 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt @@ -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, - private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter, - private val txHistoryUiActions: TxHistoryUiActions, ) { @OptIn(ExperimentalCoroutinesApi::class) - val items: Flow> = 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> = state + .filter { it.hasContent } .mapLatest { state -> state.uiBatches.asSequence() .flatMap { it.data } @@ -37,79 +29,69 @@ internal class TxHistoryUiManager( fun createOrUpdateUiBatches( newCurrencyBatches: List>>, shouldClearUiBatches: Boolean, - ): List>> { + converter: TxHistoryItemToTransactionItemUMConverter, + ): List>> { 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): List { - val items = mutableListOf() + private fun generateUiItems( + key: Int, + data: PaginationWrapper, + converter: TxHistoryItemToTransactionItemUMConverter, + ): List { + val items = mutableListOf() - // 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.transactionItemsSizeNotEqual(txInfos: List): Boolean { - return this.filterIsInstance().size != txInfos.size + private fun List.transactionItemsSizeNotEqual(txInfos: List): Boolean { + return this.filterIsInstance().size != txInfos.size } -} \ No newline at end of file +} + +private val TxHistoryListState.hasContent: Boolean + get() = status !is PaginationStatus.None && + status !is PaginationStatus.InitialLoading && + status !is PaginationStatus.InitialLoadingError \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt new file mode 100644 index 0000000000..1b8e49f8db --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt @@ -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): 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 +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverterTest.kt new file mode 100644 index 0000000000..a0d934513e --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryStatusPillConverterTest.kt @@ -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): 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 +} \ No newline at end of file