Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-25 09:21:58 +02:00
commit 98a1453d82
40 changed files with 810 additions and 207 deletions

View file

@ -105,8 +105,7 @@ class TokenActionsHandler @AssistedInject constructor(
private fun isTopUpBlockedByBackupError(action: TokenActionsBSContentUM.Action, userWallet: UserWallet): Boolean {
val isBlockedAction = action == TokenActionsBSContentUM.Action.Buy ||
action == TokenActionsBSContentUM.Action.Receive ||
action == TokenActionsBSContentUM.Action.Exchange
action == TokenActionsBSContentUM.Action.Receive
if (!isBlockedAction) return false
if (!isWalletBackupProblematicUseCase(userWallet)) return false

View file

@ -89,7 +89,7 @@
},
{
"name": "TWI_1377_MANAGE_FUNDS",
"version": "5.40"
"version": "6.0"
},
{
"name": "AND_15009_SWAP_PROVIDER_FILTER_ENABLED",

View file

@ -10,7 +10,6 @@ import androidx.compose.material3.ButtonColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
@ -190,7 +189,6 @@ fun PrimaryButtonIconStart(
enabled: Boolean = true,
tint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -205,7 +203,6 @@ fun PrimaryButtonIconStart(
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
// endregion PrimaryButton
@ -219,7 +216,6 @@ fun SecondaryButton(
showProgress: Boolean = false,
enabled: Boolean = true,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -230,7 +226,6 @@ fun SecondaryButton(
enabled = enabled,
showProgress = showProgress,
size = size,
shape = shape,
textStyle = TangemTheme.typography.subtitle1,
)
}
@ -248,7 +243,6 @@ fun SecondaryButtonIconEnd(
enabled: Boolean = true,
tint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -263,7 +257,6 @@ fun SecondaryButtonIconEnd(
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
@ -280,7 +273,6 @@ fun SecondaryButtonIconStart(
enabled: Boolean = true,
iconTint: Color? = null,
size: TangemButtonSize = TangemButtonSize.Default,
shape: Shape = size.toShape(),
) {
TangemButton(
modifier = modifier,
@ -295,7 +287,6 @@ fun SecondaryButtonIconStart(
showProgress = showProgress,
textStyle = TangemTheme.typography.subtitle1,
size = size,
shape = shape,
)
}
// endregion SecondaryButton

View file

@ -6,6 +6,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@ -14,7 +15,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@ -42,7 +42,6 @@ fun TangemButton(
size: TangemButtonSize = TangemButtonSize.Default,
elevation: ButtonElevation = TangemButtonsDefaults.elevation,
textStyle: TextStyle = TangemTheme.typography.button,
shape: Shape = size.toShape(),
iconPadding: Dp = size.toIconPadding(),
animateContentChange: Boolean = false,
) {
@ -57,7 +56,7 @@ fun TangemButton(
},
enabled = enabled,
elevation = elevation,
shape = shape,
shape = CircleShape,
colors = colors,
contentPadding = size.toContentPadding(icon = icon),
) {

View file

@ -3,7 +3,6 @@ package com.tangem.core.ui.components.buttons.common
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.res.TangemTheme
@ -32,19 +31,6 @@ internal fun TangemButtonSize.toHeightDp(): Dp = when (this) {
TangemButtonSize.Small -> 24.dp
}
@Composable
@ReadOnlyComposable
internal fun TangemButtonSize.toShape(): Shape = when (this) {
TangemButtonSize.Default -> TangemTheme.shapes.roundedCornersXMedium
TangemButtonSize.WideAction -> TangemTheme.shapes.roundedCornersMedium
TangemButtonSize.Text -> TangemTheme.shapes.roundedCornersSmall
TangemButtonSize.Selector -> TangemTheme.shapes.roundedCornersSmall
TangemButtonSize.Action -> TangemTheme.shapes.roundedCornersMedium
TangemButtonSize.TwoLines -> TangemTheme.shapes.roundedCornersXMedium
TangemButtonSize.RoundedAction -> TangemTheme.shapes.roundedCornersLarge
TangemButtonSize.Small -> TangemTheme.shapes.roundedCornersXMedium
}
@Composable
@ReadOnlyComposable
internal fun TangemButtonSize.toIconPadding(): Dp = when (this) {

View file

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
@ -43,7 +44,6 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
@ -104,7 +104,6 @@ internal fun TangemHoldToConfirmButton(
colors: ButtonColors = TangemButtonsDefaults.primaryButtonColors,
size: TangemButtonSize = TangemButtonSize.Default,
textStyle: TextStyle = TangemTheme.typography.button,
shape: Shape = size.toShape(),
) {
val coroutineScope = rememberCoroutineScope()
val state = rememberHoldToConfirmState()
@ -162,14 +161,14 @@ internal fun TangemHoldToConfirmButton(
onConfirm = onConfirm,
),
),
shape = shape,
shape = CircleShape,
color = containerColor,
) {
HoldToConfirmButtonContent(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = buttonHeight)
.clip(shape),
.clip(CircleShape),
contentPadding = contentPadding,
state = state,
shouldShowProgress = shouldShowProgress,

View file

@ -11,9 +11,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerW
@ -37,6 +39,10 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Suppress("LongMethod")
@Composable
fun TangemTopSnackbar(snackbarMessage: SnackbarMessage, modifier: Modifier = Modifier) {
val density = LocalDensity.current
val fixedFontScaleDensity = remember(density) {
Density(density = density.density, fontScale = 1f)
}
val actionLabel = snackbarMessage.actionLabel
val action = snackbarMessage.action
val hasAction = actionLabel != null && action != null
@ -46,12 +52,7 @@ fun TangemTopSnackbar(snackbarMessage: SnackbarMessage, modifier: Modifier = Mod
snackbarMessage.startIconId,
hasAction,
) { mutableStateOf(false) }
val shape = if (isTextOverflowing) {
RoundedCornerShape(TangemTheme.dimens2.x5)
} else {
TangemTheme.shapes.roundedCornersXLarge
}
val shape = RoundedCornerShape(24.dp)
Column(
modifier = modifier
.shadow(elevation = TangemTheme.dimens.elevation4, shape = shape, clip = false)
@ -59,8 +60,8 @@ fun TangemTopSnackbar(snackbarMessage: SnackbarMessage, modifier: Modifier = Mod
.clip(shape)
.hazeEffectTangem()
.sizeIn(minHeight = TangemTheme.dimens2.x11)
.padding(start = TangemTheme.dimens2.x5, end = TangemTheme.dimens2.x3)
.padding(vertical = TangemTheme.dimens2.x1),
.padding(start = TangemTheme.dimens2.x4, end = TangemTheme.dimens2.x3)
.padding(vertical = TangemTheme.dimens2.x2),
verticalArrangement = Arrangement.Center,
) {
Row(
@ -78,18 +79,20 @@ fun TangemTopSnackbar(snackbarMessage: SnackbarMessage, modifier: Modifier = Mod
SpacerW(TangemTheme.dimens2.x2)
}
Text(
text = snackbarMessage.message.resolveReference(),
modifier = Modifier.weight(1f, fill = false),
color = TangemTheme.colors.text.secondary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TangemTheme.typography.body2,
onTextLayout = { layoutResult ->
val isOverflowing = hasAction && layoutResult.hasVisualOverflow
if (isTextOverflowing != isOverflowing) isTextOverflowing = isOverflowing
},
)
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = snackbarMessage.message.resolveReference(),
modifier = Modifier.weight(1f, fill = false),
color = TangemTheme.colors2.text.neutral.secondary,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
style = TangemTheme.typography2.captionMedium13,
onTextLayout = { layoutResult ->
val isOverflowing = hasAction && layoutResult.hasVisualOverflow
if (isTextOverflowing != isOverflowing) isTextOverflowing = isOverflowing
},
)
}
SpacerW(TangemTheme.dimens2.x4)
@ -177,6 +180,7 @@ private fun Preview_TangemTopSnackbar_NoAction() {
)
TangemTopSnackbar(
snackbarMessage = SnackbarMessage(
startIconId = R.drawable.ic_eye_off_outline_24,
message = stringReference(
"Operation completed long long text that should be truncated with ellipsis at the end",
),

View file

@ -6,7 +6,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material3.CircularProgressIndicator
@ -229,19 +229,14 @@ private fun TangemButtonIcon(tangemIconUM: TangemIconUM?, isVisible: Boolean, si
/**
* Defines the shape of the Tangem button.
*/
@Deprecated("All buttons are rounded by design")
enum class TangemButtonShape {
Default,
Rounded,
;
@ReadOnlyComposable
@Composable
internal fun toShape(size: TangemButtonSize) = RoundedCornerShape(
when (this) {
Default -> size.toShapeRadius()
Rounded -> 100.dp
},
)
@Suppress("UnusedParameter")
internal fun toShape(size: TangemButtonSize) = CircleShape
}
/**
@ -309,18 +304,6 @@ enum class TangemButtonSize {
-> TangemTheme.dimens2.x7
}
@ReadOnlyComposable
@Composable
internal fun toShapeRadius() = when (this) {
X7,
X8,
X9,
X10,
-> TangemTheme.dimens2.x2
X12 -> TangemTheme.dimens2.x3
X15 -> TangemTheme.dimens2.x4
}
@ReadOnlyComposable
@Composable
internal fun toTextStyle(): TextStyle = when (this) {

View file

@ -14,7 +14,7 @@ import com.tangem.core.ui.extensions.TextReference
* @param isEnabled Boolean indicating whether the button is enabled.
* @param isLoading Boolean indicating whether the button is in a loading state.
* @param size TangemButtonSize defining the size of the button.
* @param shape TangemButtonShape defining the shape of the button.
* @param shape TangemButtonShape defining the shape of the button. UPD all buttons are rounded.
* @param type TangemButtonType defining the style type of the button.
* @param onClick Lambda to be invoked when the button is clicked.
* @param onLongClick Lambda to be invoked when the button is long-clicked.

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -256,7 +257,7 @@ private fun TangemMessageContent(
}
// The close button is drawn over the top-end corner, so in the leading-content layout the texts
// reserve trailing space to never run under it; trailing-content layouts are kept untouched
val isCloseSpaceReserved = hasCloseButton && leadingContent != null && content == null
val isCloseSpaceReserved = hasCloseButton && content == null
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
modifier = Modifier.padding(TangemTheme.dimens2.x1),
@ -280,7 +281,8 @@ private fun TangemMessageContent(
text = title.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = textAlign,
modifier = Modifier.testTag(NotificationTestTags.TITLE),
)

View file

@ -96,16 +96,15 @@ fun TangemTopNavigation(
end = 16.dp,
),
content = {
// Each optional slot caches its last non-null content so the spring exit transition
// still has something to render after the caller flips it back to `null`.
val displayedStart = rememberLastNonNull(startButton)
AnimatedVisibility(
modifier = Modifier.layoutId(SlotId.Start),
visible = startButton != null,
enter = slotEnter,
exit = slotExit,
) {
displayedStart?.invoke()
Box(modifier = Modifier.layoutId(SlotId.Start)) {
AnimatedVisibility(
visible = startButton != null,
enter = slotEnter,
exit = slotExit,
) {
displayedStart?.invoke()
}
}
Column(
@ -121,30 +120,32 @@ fun TangemTopNavigation(
}
val displayedGroup = rememberLastNonNull(endButtonsGroup)
AnimatedVisibility(
modifier = Modifier.layoutId(SlotId.Group),
visible = endButtonsGroup != null,
enter = slotEnter,
exit = slotExit,
) {
displayedGroup?.let { group ->
TangemSurface(isMaterial = true, shape = CircleShape) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
content = group,
)
Box(modifier = Modifier.layoutId(SlotId.Group)) {
AnimatedVisibility(
visible = endButtonsGroup != null,
enter = slotEnter,
exit = slotExit,
) {
displayedGroup?.let { group ->
TangemSurface(isMaterial = true, shape = CircleShape) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
content = group,
)
}
}
}
}
val displayedEnd = rememberLastNonNull(endButton)
AnimatedVisibility(
modifier = Modifier.layoutId(SlotId.End),
visible = endButton != null,
enter = slotEnter,
exit = slotExit,
) {
displayedEnd?.invoke()
Box(modifier = Modifier.layoutId(SlotId.End)) {
AnimatedVisibility(
visible = endButton != null,
enter = slotEnter,
exit = slotExit,
) {
displayedEnd?.invoke()
}
}
},
) { measurables, constraints ->

View file

@ -7,11 +7,14 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import io.mockk.Deregisterable
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.registerInstanceFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -25,11 +28,23 @@ class IsYieldBoostPromoEnabledForTokenUseCaseTest {
private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
private val networkRawId = "ethereum"
// Stub concrete instances of the sealed return types so MockK doesn't subclass them while recording coEvery
// (Objenesis on a JVM-sealed type throws InstantiationError flakily under full-suite CI runs).
private val instanceFactories = mutableListOf<Deregisterable>()
@BeforeEach
fun setUp() {
instanceFactories += registerInstanceFactory { YieldBoostPromo.None }
instanceFactories += registerInstanceFactory { YieldBoostStatus.NotStarted }
useCase = IsYieldBoostPromoEnabledForTokenUseCase(repository = repository)
}
@AfterEach
fun tearDown() {
instanceFactories.forEach { it.deregister() }
instanceFactories.clear()
}
@Test
fun `GIVEN currency is coin WHEN invoke THEN returns Right(false)`() = runTest {
val coin = createCoin()

View file

@ -5,11 +5,14 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import io.mockk.Deregisterable
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.registerInstanceFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -23,11 +26,23 @@ class ShouldShowYieldBoostMainBannerUseCaseTest {
private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
private val networkRawId = "ethereum"
// Stub concrete instances of the sealed return types so MockK doesn't subclass them while recording coEvery
// (Objenesis on a JVM-sealed type throws InstantiationError flakily under full-suite CI runs).
private val instanceFactories = mutableListOf<Deregisterable>()
@BeforeEach
fun setUp() {
instanceFactories += registerInstanceFactory { YieldBoostPromo.None }
instanceFactories += registerInstanceFactory { YieldBoostStatus.NotStarted }
useCase = ShouldShowYieldBoostMainBannerUseCase(repository = repository)
}
@AfterEach
fun tearDown() {
instanceFactories.forEach { it.deregister() }
instanceFactories.clear()
}
@Test
fun `GIVEN promo repository throws WHEN invoke THEN returns Left`() = runTest {
coEvery { repository.getYieldBoostPromo(userWalletId, false) } throws RuntimeException("net")

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
@ -17,6 +18,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
@ -84,7 +86,9 @@ internal class DefaultFeedComponent(
FeedList(
modifier = modifier,
state = state,
promoBannersBlockComponent = promoBannersBlockComponent,
promoBannersBlockComponent = ComposableContentComponent { promoModifier ->
promoBannersBlockComponent.ContentWithPadding(modifier = promoModifier, horizontalItemPadding = 16.dp)
},
contentPadding = contentPadding,
)
bottomSheet.child?.instance?.BottomSheet()

View file

@ -114,9 +114,8 @@ private fun FeedListContent(
modifier = Modifier.padding(horizontal = 16.dp),
)
promoBannersBlockComponent?.Content(
modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp),
)
promoBannersBlockComponent?.Content(modifier = Modifier.padding(top = 12.dp))
SpacerH(32.dp)
NewsBlock(

View file

@ -24,7 +24,6 @@ internal fun StoriesButton(
colors = if (useDarkerColors) DarkerButtonColors else LighterButtonColors,
showProgress = showProgress,
enabled = true,
shape = TangemTheme.shapes.roundedCornersXMedium,
textStyle = TangemTheme.typography.subtitle1,
iconPadding = when (icon) {
is TangemButtonIconPosition.Start -> TangemTheme.dimens.spacing4

View file

@ -1,9 +1,14 @@
package com.tangem.features.promobanners.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface PromoBannersBlockComponent : ComposableContentComponent {
interface PromoBannersBlockComponent {
@Composable
fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier)
fun setVisibleOnScreen(isVisible: Boolean)

View file

@ -3,6 +3,7 @@ package com.tangem.features.promobanners.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
@ -25,11 +26,12 @@ internal class DefaultPromoBannersBlockComponent @AssistedInject constructor(
}
@Composable
override fun Content(modifier: Modifier) {
override fun ContentWithPadding(horizontalItemPadding: Dp, modifier: Modifier) {
val state by model.uiState.collectAsStateWithLifecycle()
PromoBannersBlock(
state = state,
modifier = modifier,
horizontalItemPadding = horizontalItemPadding,
)
}

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import com.tangem.core.ui.components.SpacerH8
@ -36,7 +37,7 @@ import kotlin.math.ceil
import kotlin.math.floor
@Composable
internal fun PromoBannersBlock(state: PromoBannersBlockUM, modifier: Modifier = Modifier) {
internal fun PromoBannersBlock(state: PromoBannersBlockUM, horizontalItemPadding: Dp, modifier: Modifier = Modifier) {
if (state.banners.isEmpty()) return
val containerColor = bannerContainerColor(state.placeholder)
@ -51,13 +52,14 @@ internal fun PromoBannersBlock(state: PromoBannersBlockUM, modifier: Modifier =
SingleBanner(
banner = banner,
containerColor = containerColor,
modifier = modifier,
modifier = modifier.padding(horizontal = horizontalItemPadding),
)
} else {
key(state.userWalletId) {
BannersCarousel(
state = state,
containerColor = containerColor,
horizontalPadding = horizontalItemPadding,
modifier = modifier,
)
}
@ -109,7 +111,12 @@ private fun SingleBanner(banner: PromoBannerNotificationUM, containerColor: Colo
}
@Composable
private fun BannersCarousel(state: PromoBannersBlockUM, containerColor: Color, modifier: Modifier = Modifier) {
private fun BannersCarousel(
state: PromoBannersBlockUM,
containerColor: Color,
horizontalPadding: Dp,
modifier: Modifier = Modifier,
) {
val pagerState = rememberPagerState(
initialPage = state.initialPage,
pageCount = { state.banners.size },
@ -139,6 +146,7 @@ private fun BannersCarousel(state: PromoBannersBlockUM, containerColor: Color, m
banners = state.banners,
pagerState = pagerState,
containerColor = containerColor,
horizontalPadding = horizontalPadding,
modifier = Modifier.fillMaxWidth(),
)
@ -163,6 +171,7 @@ private fun SmoothHeightPager(
banners: List<PromoBannerNotificationUM>,
pagerState: PagerState,
containerColor: Color,
horizontalPadding: Dp,
modifier: Modifier = Modifier,
) {
SubcomposeLayout(modifier = modifier) { constraints ->
@ -206,6 +215,7 @@ private fun SmoothHeightPager(
) { page ->
val banner = banners.getOrNull(page) ?: return@HorizontalPager
BannerNotification(
modifier = Modifier.padding(horizontal = horizontalPadding),
config = banner.config,
containerColor = containerColor,
)
@ -253,7 +263,11 @@ private fun previewState(bannerCount: Int) = PromoBannersBlockUM(
@Composable
private fun Preview_PromoBannersBlock_Legacy() {
TangemThemePreview {
PromoBannersBlock(state = previewState(bannerCount = 2), modifier = Modifier.padding(16.dp))
PromoBannersBlock(
state = previewState(bannerCount = 2),
horizontalItemPadding = 12.dp,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}
@ -262,7 +276,11 @@ private fun Preview_PromoBannersBlock_Legacy() {
@Composable
private fun Preview_PromoBannersBlock_Redesign() {
TangemThemePreviewRedesign {
PromoBannersBlock(state = previewState(bannerCount = 2), modifier = Modifier.padding(16.dp))
PromoBannersBlock(
state = previewState(bannerCount = 2),
modifier = Modifier.padding(vertical = 16.dp),
horizontalItemPadding = 12.dp,
)
}
}
// endregion

View file

@ -621,10 +621,11 @@ internal class StakingModel @Inject constructor(
}
override fun onAmountEnterClick() {
if (integration.preferredTargets.isEmpty()) {
val actionType = uiState.value.actionType
if (actionType !is StakingActionCommonType.Exit && integration.preferredTargets.isEmpty()) {
messageSender.send(StakingAlertUM.noAvailableValidators())
} else {
if (uiState.value.actionType is StakingActionCommonType.Enter) {
if (actionType is StakingActionCommonType.Enter) {
stateController.updateAll(
ValidatorSelectChangeTransformer(
selectedTarget = null,

View file

@ -354,6 +354,24 @@ internal class StakingModelNavigationTest : StakingModelTestBase() {
model.onDestroy()
}
@Test
fun `GIVEN empty preferredTargets AND Exit action WHEN onAmountEnterClick THEN no alert sent`() = runTest {
every { stateController.value } returns initialUiState
every { initialUiState.actionType } returns StakingActionCommonType.Exit(partiallyUnstakeDisabled = false)
every { testYield.preferredValidators } returns emptyList()
every { messageSender.send(any()) } just Runs
val model = createModel(testScope = this)
advanceUntilIdle()
model.onAmountEnterClick()
advanceUntilIdle()
verify(exactly = 0) { messageSender.send(any()) }
model.onDestroy()
}
@Test
fun `GIVEN non-empty preferredTargets WHEN onAmountEnterClick THEN validator reset and onNextClick called`() =
runTest {

View file

@ -54,6 +54,7 @@ sealed interface SwapState {
val isAccountsMode: Boolean,
val isFeeCoverage: Boolean,
val sendingAmount: BigDecimal,
val isSendingAmountLoading: Boolean = false,
val currencyCheck: CryptoCurrencyCheck? = null,
val validationResult: Throwable? = null,
val minAdaValue: BigDecimal? = null,

View file

@ -40,7 +40,6 @@ import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkFeeCoverage
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.extensions.orZero
@ -107,7 +106,7 @@ class SwapTransferInteractorImpl @Inject constructor(
fee = warningsFee,
feeCurrencyBalanceAfterTransaction = null,
)
val (isFeeCoverage, sendingAmount) = getCoverageState(
val coverageState = getCoverageState(
fromTokenInfo = fromTokenInfo,
userWallet = userWallet,
fee = fee,
@ -130,8 +129,9 @@ class SwapTransferInteractorImpl @Inject constructor(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,
isFeeCoverage = isFeeCoverage,
sendingAmount = sendingAmount,
isFeeCoverage = coverageState.isFeeCoverage,
sendingAmount = coverageState.sendingAmount,
isSendingAmountLoading = coverageState.isSendingAmountLoading,
currencyCheck = currencyCheck,
)
}
@ -155,7 +155,7 @@ class SwapTransferInteractorImpl @Inject constructor(
userWallet: UserWallet,
fee: Fee?,
currencyCheck: CryptoCurrencyCheck,
): Pair<Boolean, BigDecimal> {
): CoverageState {
val swapCurrencyStatus = fromTokenInfo.swapCurrencyStatus
val isAmountSubtractAvailable = isAmountSubtractAvailable(
userWalletId = userWallet.walletId,
@ -173,16 +173,29 @@ class SwapTransferInteractorImpl @Inject constructor(
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
val sendingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = fromTokenInfo.swapCurrencyStatus.status,
amountValue = amount.value,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
// When fee coverage applies, the entered amount can't be sent together with the fee, so the
// sent (and therefore received) amount is the entered amount reduced by the fee. This tracks the
// input: as the user edits the amount, the received amount changes with it.
val sendingAmount = if (isFeeCoverage) {
(amount.value - feeValue).coerceAtLeast(BigDecimal.ZERO)
} else {
amount.value
}
// While subtraction is possible but the fee has not loaded yet, the final received amount
// (entered - fee) is unknown, so it must be shown as loading instead of the un-subtracted value.
return CoverageState(
isFeeCoverage = isFeeCoverage,
sendingAmount = sendingAmount,
isSendingAmountLoading = fee == null && isAmountSubtractAvailable,
)
return isFeeCoverage to sendingAmount
}
private data class CoverageState(
val isFeeCoverage: Boolean,
val sendingAmount: BigDecimal,
val isSendingAmountLoading: Boolean,
)
private suspend fun isAmountSubtractAvailable(
userWalletId: UserWalletId,
currency: CryptoCurrency,

View file

@ -299,7 +299,7 @@ internal class SwapTransferInteractorImplTest {
} returns true.right()
// entered amount = full balance → balance < amount + fee, balance > fee, balance >= amount
// → isFeeCoverage = true, sendingAmount = balance - fee
// → isFeeCoverage = true, sendingAmount = entered - fee (== balance - fee at max)
val result = sut.updateTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
@ -310,6 +310,113 @@ internal class SwapTransferInteractorImplTest {
assertThat(result.isFeeCoverage).isTrue()
assertThat(result.sendingAmount).isEqualTo(balance - feeValue)
assertThat(result.isSendingAmountLoading).isFalse()
}
@Test
fun `GIVEN subtract available and sub-max amount in coverage zone WHEN updateTransfer THEN sendingAmount is entered minus fee`() =
runTest {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val userWallet: UserWallet = mockk(relaxed = true)
val balance = BigDecimal("1.5")
val feeValue = BigDecimal("0.2")
// entered is below the balance but still within one fee of it → coverage applies, yet the
// received amount must track the entered amount (entered - fee), not clamp to balance - fee.
val enteredAmount = BigDecimal("1.45")
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
fiatRate = BigDecimal.TEN,
amount = balance,
userWallet = userWallet,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
userWallet = userWallet,
)
val fee: Fee = mockk(relaxed = true) {
every { amount.value } returns feeValue
}
every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right())
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery {
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns buildCurrencyCheck()
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns true.right()
val result = sut.updateTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = enteredAmount.toPlainString(),
feePaidCurrencyStatus = null,
fee = fee,
) as SwapState.Transfer
assertThat(result.isFeeCoverage).isTrue()
assertThat(result.sendingAmount).isEqualTo(enteredAmount - feeValue)
// it must NOT clamp to balance - fee
assertThat(result.sendingAmount).isNotEqualTo(balance - feeValue)
}
@Test
fun `GIVEN subtract available but fee not loaded yet WHEN updateTransfer THEN isSendingAmountLoading is true`() =
runTest {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val userWallet: UserWallet = mockk(relaxed = true)
val balance = BigDecimal("1.5")
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
fiatRate = BigDecimal.TEN,
amount = balance,
userWallet = userWallet,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
userWallet = userWallet,
)
every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right())
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery {
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns buildCurrencyCheck()
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns true.right()
// subtraction is possible but the fee has not loaded yet (fee = null) → the received amount
// depends on the fee, so it can't be known yet and must be reported as loading.
val result = sut.updateTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = balance.toPlainString(),
feePaidCurrencyStatus = null,
fee = null,
) as SwapState.Transfer
assertThat(result.isSendingAmountLoading).isTrue()
}
// endregion

View file

@ -21,6 +21,7 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.simple
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
@ -31,11 +32,11 @@ import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
import com.tangem.feature.swap.ui.swapSuccessNavigation
import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
import com.tangem.feature.swap.ui.swapSuccessNavigation
import com.tangem.features.send.api.utils.formatFooterFiatFee
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
import com.tangem.utils.extensions.orZero
@ -59,11 +60,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
fee: Fee?,
): SwapStateHolder {
val fromTokenSwapInfo = transferState.fromTokenInfo
val toTokenSwapInfo = transferState.toTokenInfo
val isInsufficientBalance = transferState.isInsufficientBalance
val prevSendCard = uiStateHolder.sendCardData as? SwapCardState.SwapCardData
val prevAmountField = prevSendCard?.amountField
val displayValue = prevAmountField?.value.orEmpty()
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
@ -75,25 +74,14 @@ internal class SwapTransferStateBuilder @Inject constructor(
return uiStateHolder.copy(
sendCardData = createSendSwapCardState(
actions = actions,
displayValue = displayValue,
tokenSwapInfo = fromTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
isBalanceHidden = transferState.isBalanceHidden,
isInsufficientBalance = isInsufficientBalance,
prevAmountField = prevAmountField,
),
receiveCardData = createSendSwapCardState(
actions = actions,
displayValue = displayValue,
tokenSwapInfo = toTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
isFromCard = false,
isBalanceHidden = transferState.isBalanceHidden,
isInsufficientBalance = isInsufficientBalance,
),
receiveCardData = createReceiveCard(actions = actions, transferState = transferState),
isInsufficientFunds = isInsufficientBalance,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(transferState.userWallet),
@ -109,14 +97,12 @@ internal class SwapTransferStateBuilder @Inject constructor(
@Suppress("LongParameterList")
private fun createSendSwapCardState(
actions: UiActions,
displayValue: String,
tokenSwapInfo: TokenSwapInfo,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
isFromCard: Boolean,
isBalanceHidden: Boolean,
isInsufficientBalance: Boolean,
prevAmountField: AmountFieldModel? = null,
prevAmountField: AmountFieldModel?,
): SwapCardState {
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
val currency = swapCurrencyStatus.currency
@ -126,7 +112,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
actions = actions,
swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus,
isAccountsMode = isAccountsMode,
isFromCard = isFromCard,
isFromCard = true,
isInsufficientBalance = isInsufficientBalance,
),
currencyIconState = iconConverter.convert(
@ -140,18 +126,61 @@ internal class SwapTransferStateBuilder @Inject constructor(
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHidden,
appCurrency = appCurrency,
amountField = if (isFromCard) {
buildAmountField(
actions = actions,
prevAmountField = prevAmountField,
swapCurrencyStatus = swapCurrencyStatus,
appCurrency = appCurrency,
)
amountField = buildAmountField(
actions = actions,
prevAmountField = prevAmountField,
swapCurrencyStatus = swapCurrencyStatus,
appCurrency = appCurrency,
),
)
}
/**
* Builds the read-only receive card from [SwapState.Transfer.sendingAmount] the amount that will
* actually be received, already reduced by the fee when fee coverage applies. While the reduced amount
* is not yet known (fee still loading) the amount and fiat fields are null, which makes the read-only
* card render a shimmer instead of the un-subtracted value.
*/
private fun createReceiveCard(actions: UiActions, transferState: SwapState.Transfer): SwapCardState {
val toTokenSwapInfo = transferState.toTokenInfo
val swapCurrencyStatus = toTokenSwapInfo.swapCurrencyStatus
val currency = swapCurrencyStatus.currency
val appCurrency = transferState.appCurrency
val sendingAmount = transferState.sendingAmount
// No reduction can happen when the balance is insufficient (fee coverage requires balance >= amount),
// so there is nothing to wait for — show the amount instead of a shimmer.
val isLoading = transferState.isSendingAmountLoading && !transferState.isInsufficientBalance
val fiatRate = swapCurrencyStatus.status.value.fiatRate
return SwapCardState.SwapCardData(
type = createSendTransactionCardType(
actions = actions,
swapCurrencyStatus = swapCurrencyStatus,
isAccountsMode = transferState.isAccountsMode,
isFromCard = false,
isInsufficientBalance = transferState.isInsufficientBalance,
),
currencyIconState = iconConverter.convert(
value = swapCurrencyStatus.status,
),
tokenSymbol = stringReference(currency.symbol),
amountEquivalent = if (isLoading) {
null
} else {
// Read-only receive card mirrors the same display value the "from" card shows in transfer mode.
getFormattedFiatAmount(appCurrency = appCurrency, amount = fiatRate?.multiply(sendingAmount))
},
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = transferState.isBalanceHidden,
appCurrency = appCurrency,
amountField = if (isLoading) {
null
} else {
val value = sendingAmount.format {
simple(decimals = currency.decimals)
}
displayAmountField(
actions = actions,
value = displayValue,
value = value,
swapCurrencyStatus = swapCurrencyStatus,
appCurrency = appCurrency,
)
@ -324,6 +353,10 @@ internal class SwapTransferStateBuilder @Inject constructor(
)
return uiStateHolder.copy(
notifications = notifications,
// Rebuild the receive card from the refreshed transferState: this path runs after the fee
// selector resolves, when sendingAmount may have just been reduced by the fee. Only the
// receive card is rebuilt to avoid clobbering the user's in-progress input on the "from" card.
receiveCardData = createReceiveCard(actions = actions, transferState = transferState),
swapButton = uiStateHolder.swapButton.copy(
isEnabled = getTransferButtonEnabled(notifications, fee, isTangemPayWithdrawal),
),

View file

@ -22,6 +22,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fee
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.network.Network
@ -371,6 +372,123 @@ internal class SwapTransferStateBuilderTest {
}
}
@Test
fun `GIVEN fee coverage reduces amount WHEN createTransferState THEN receive card shows reduced sendingAmount`() =
runTest {
// entered 1.5, but fee coverage reduces the received (sending) amount to 1.3
val sendingAmount = BigDecimal("1.3")
val transferState = buildTransferState(
fromAmount = BigDecimal("1.5"),
toAmount = sendingAmount,
isAccountsMode = false,
isFeeCoverage = true,
)
val result = sut.createTransferState(
actions = actions,
transferState = transferState,
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = mockk(relaxed = true),
)
val sendCard = result.sendCardData as SwapCardState.SwapCardData
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
val expectedFiat = stringReference(
toCurrencyStatus.status.value.fiatRate!!.multiply(sendingAmount).format {
fiat(fiatCurrencyCode = AppCurrency.Default.code, fiatCurrencySymbol = AppCurrency.Default.symbol)
},
)
// the "from" card keeps the user's typed value, the receive card shows the reduced amount + its fiat
assertThat(sendCard.amountField?.value).isEqualTo(initialAmountValue)
assertThat(receiveCard.amountField?.value).isEqualTo(
sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
)
assertThat(receiveCard.amountEquivalent).isEqualTo(expectedFiat)
}
@Test
fun `GIVEN sendingAmount loading WHEN createTransferState THEN receive card amount and fiat shimmer`() =
runTest {
// fee not loaded yet → reduced amount unknown → receive card shimmers (null amount + null fiat)
val transferState = buildTransferState(
fromAmount = BigDecimal("1.5"),
toAmount = BigDecimal("1.5"),
isAccountsMode = false,
isSendingAmountLoading = true,
)
val result = sut.createTransferState(
actions = actions,
transferState = transferState,
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = null,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
assertThat(receiveCard.amountField).isNull()
assertThat(receiveCard.amountEquivalent).isNull()
}
@Test
fun `GIVEN sendingAmount loading but insufficient balance WHEN createTransferState THEN receive card shows amount not shimmer`() =
runTest {
// Insufficient balance → fee coverage can't apply, so there is no reduction to wait for.
// The receive card must show the amount instead of shimmering.
val amount = BigDecimal("99")
val transferState = buildTransferState(
fromAmount = amount,
toAmount = amount,
isAccountsMode = false,
isInsufficientBalance = true,
isSendingAmountLoading = true,
)
val result = sut.createTransferState(
actions = actions,
transferState = transferState,
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = null,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
assertThat(receiveCard.amountField?.value).isEqualTo(
amount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
)
assertThat(receiveCard.amountEquivalent).isNotNull()
}
@Test
fun `GIVEN reduced sendingAmount WHEN updateTransferButtonEnableState THEN receive card is rebuilt to reduced amount`() =
runTest {
// After the fee resolves, sendingAmount is reduced; the refresh path must rebuild the receive card
// so it no longer shows the stale full amount.
val sendingAmount = BigDecimal("1.3")
val transferState = buildTransferState(
fromAmount = BigDecimal("1.5"),
toAmount = sendingAmount,
isAccountsMode = false,
isFeeCoverage = true,
)
val result = sut.updateTransferButtonEnableState(
dataState = SwapProcessDataState(),
transferState = transferState,
actions = actions,
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = mockk(relaxed = true),
isTangemPayWithdrawal = false,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
assertThat(receiveCard.amountField?.value).isEqualTo(
sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
)
}
@Test
fun `GIVEN Tron fee WHEN updateTransferButtonEnableState THEN transferFooter uses Tron token fee sending text`() =
runTest {
@ -747,8 +865,12 @@ internal class SwapTransferStateBuilderTest {
) {
val sendCard = result.sendCardData as SwapCardState.SwapCardData
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
// The "from" card preserves the user's typed value; the receive card shows the (possibly
// fee-reduced) sendingAmount formatted with the receive token's decimals.
assertThat(sendCard.amountField?.value).isEqualTo(initialAmountValue)
assertThat(receiveCard.amountField?.value).isEqualTo(initialAmountValue)
assertThat(receiveCard.amountField?.value).isEqualTo(
transferState.sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
)
assertThat(sendCard.currencyIconState).isEqualTo(fromIcon)
assertThat(receiveCard.currencyIconState).isEqualTo(toIcon)
assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden)
@ -778,6 +900,8 @@ internal class SwapTransferStateBuilderTest {
toAmount: BigDecimal,
isAccountsMode: Boolean,
isInsufficientBalance: Boolean = false,
isFeeCoverage: Boolean = false,
isSendingAmountLoading: Boolean = false,
): SwapState.Transfer {
val fromInfo = TokenSwapInfo(
tokenAmount = SwapAmount(value = fromAmount, decimals = fromCurrencyStatus.currency.decimals),
@ -798,8 +922,9 @@ internal class SwapTransferStateBuilderTest {
appCurrency = AppCurrency.Default,
isBalanceHidden = false,
isAccountsMode = isAccountsMode,
isFeeCoverage = false,
isFeeCoverage = isFeeCoverage,
sendingAmount = toAmount,
isSendingAmountLoading = isSendingAmountLoading,
)
}

View file

@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_document_20
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
@ -59,9 +60,11 @@ internal class TangemPayDetailsStateFactory(
}
fun getLoadedState(status: PaymentAccountStatusValue.Loaded): TangemPayDetailsUM {
val isFresh = status.source == StatusSource.ACTUAL && status.error == null
val hasUnfrozenCard = status.cards.any { it.frozenState == TangemPayCardFrozenState.Unfrozen }
val hasIssuingCard = status.cards.any { it.state == TangemPayCardState.Issuing }
val isAddCardEnabled = status.error == null && !hasIssuingCard
val isAddCardEnabled = isFresh && !hasIssuingCard
val areActionButtonsEnabled = isFresh && hasUnfrozenCard
return TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = onBack,
@ -74,7 +77,7 @@ internal class TangemPayDetailsStateFactory(
onRefresh = intents::onRefreshSwipe,
),
balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(
actionButtons = getActionButtonsConfig(isEnabled = status.error == null && hasUnfrozenCard),
actionButtons = getActionButtonsConfig(isEnabled = areActionButtonsEnabled),
cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState(
cards = status.cards
.let { if (isMultipleCardsEnabled) it else it.take(1) }

View file

@ -0,0 +1,164 @@
package com.tangem.features.tangempay.entity
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.features.tangempay.utils.TangemPayDetailIntents
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
internal class TangemPayDetailsStateFactoryTest {
private val intents: TangemPayDetailIntents = mockk(relaxed = true)
@BeforeEach
fun resetMocks() {
clearMocks(intents)
}
private val activeUnfrozenCard = TangemPayCard(
id = "card_1",
productInstanceId = "pi_card_1",
cardStatus = TangemPayCard.Status.ACTIVE,
hasPinCode = false,
displayName = null,
frozenState = TangemPayCardFrozenState.Unfrozen,
lastDigits = "1234",
limit = null,
state = TangemPayCardState.Active,
)
private fun createFactory() = TangemPayDetailsStateFactory(
onBack = {},
onOpenMenu = {},
intents = intents,
isRedesignEnabled = true,
isRemoveAccountEnabled = true,
isMultipleCardsEnabled = true,
)
private fun loadedStatus(
statusSource: StatusSource,
statusError: PaymentAccountStatusValue.Error?,
statusCards: List<TangemPayCard> = listOf(activeUnfrozenCard),
): PaymentAccountStatusValue.Loaded = mockk(relaxed = true) {
every { source } returns statusSource
every { error } returns statusError
every { cards } returns statusCards
}
@ParameterizedTest
@MethodSource("provideButtonStateCases")
fun `GIVEN status source WHEN getLoadedState THEN action buttons gated by freshness but card tile only by error`(
case: ButtonStateCase,
) {
// Arrange
val factory = createFactory()
val status = loadedStatus(statusSource = case.source, statusError = case.error)
// Act
val state = factory.getLoadedState(status)
// Assert
val actionButtonsEnabled = state.balanceBlockState.actionButtons.map { it.isEnabled }
assertThat(actionButtonsEnabled).containsExactly(case.expectedEnabled, case.expectedEnabled)
assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isEqualTo(case.expectedEnabled)
// The card tile is intentionally NOT source-gated: it stays clickable on stale data as long as
// there is no error, so the user can still view the card details offline (which self-gate actions).
assertThat(state.balanceBlockState.cardsBlockState?.cards?.single()?.isEnabled)
.isEqualTo(case.expectedCardEnabled)
}
@Test
fun `GIVEN actual status with only frozen card WHEN getLoadedState THEN action buttons disabled`() {
// Arrange
val factory = createFactory()
val frozenCard = activeUnfrozenCard.copy(frozenState = TangemPayCardFrozenState.Frozen)
val status = loadedStatus(
statusSource = StatusSource.ACTUAL,
statusError = null,
statusCards = listOf(frozenCard),
)
// Act
val state = factory.getLoadedState(status)
// Assert
assertThat(state.balanceBlockState.actionButtons.map { it.isEnabled }).containsExactly(false, false)
assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isTrue()
}
@Test
fun `GIVEN actual status with issuing card WHEN getLoadedState THEN add card disabled`() {
// Arrange
val factory = createFactory()
val issuingCard = activeUnfrozenCard.copy(state = TangemPayCardState.Issuing)
val status = loadedStatus(
statusSource = StatusSource.ACTUAL,
statusError = null,
statusCards = listOf(activeUnfrozenCard, issuingCard),
)
// Act
val state = factory.getLoadedState(status)
// Assert
assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isFalse()
}
internal data class ButtonStateCase(
val source: StatusSource,
val error: PaymentAccountStatusValue.Error?,
val expectedEnabled: Boolean,
val expectedCardEnabled: Boolean,
)
private companion object {
@JvmStatic
fun provideButtonStateCases() = listOf(
// Fresh data from the network -> actions allowed, card tile clickable.
ButtonStateCase(
source = StatusSource.ACTUAL,
error = null,
expectedEnabled = true,
expectedCardEnabled = true,
),
// Cache restored from disk before a refresh confirms it -> actions blocked, card tile clickable.
ButtonStateCase(
source = StatusSource.CACHE,
error = null,
expectedEnabled = false,
expectedCardEnabled = true,
),
// Internet unavailable, only cache left ([REDACTED_TASK_KEY] case 1) -> actions blocked, card tile clickable.
ButtonStateCase(
source = StatusSource.ONLY_CACHE,
error = null,
expectedEnabled = false,
expectedCardEnabled = true,
),
// Expired refresh token, only cache left ([REDACTED_TASK_KEY] case 2) -> everything blocked by the error.
ButtonStateCase(
source = StatusSource.ONLY_CACHE,
error = PaymentAccountStatusValue.Error.NotSynced,
expectedEnabled = false,
expectedCardEnabled = false,
),
// Transient error overlaid on actual data -> everything blocked by the error.
ButtonStateCase(
source = StatusSource.ACTUAL,
error = PaymentAccountStatusValue.Error.Unavailable,
expectedEnabled = false,
expectedCardEnabled = false,
),
)
}
}

View file

@ -533,7 +533,7 @@ internal class TokenDetailsModel @Inject constructor(
network = cryptoCurrency.network,
).getOrElse { false }
val isSupported = isXPUBSupported()
val isSupported = isXpubSupported()
val isDynamicAddressesAvailable = isSupported &&
isDynamicAddressesAvailableUseCase(userWallet, cryptoCurrency)
@ -546,7 +546,7 @@ internal class TokenDetailsModel @Inject constructor(
}
}
private suspend fun isXPUBSupported(): Boolean {
private suspend fun isXpubSupported(): Boolean {
return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network)
}
@ -853,7 +853,6 @@ internal class TokenDetailsModel @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) {
return
}
if (isTopUpBlockedByBackupError()) return
modelScope.launch {
if (checkYieldSupply && needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)) {
@ -1438,15 +1437,17 @@ internal class TokenDetailsModel @Inject constructor(
network = cryptoCurrency.network,
).getOrElse { false }
val isSupported = isXPUBSupported()
val isXpubSupported = isXpubSupported()
val isDynamicAddressesAvailable = isXpubSupported &&
isDynamicAddressesAvailableUseCase(userWallet, cryptoCurrency)
redesignStateController.update(
UpdateTopBarMenuTransformer(
userWallet = userWallet,
hasDerivations = hasDerivations,
isXPubSupported = isSupported,
onGenerateExtendedKey = ::onGenerateExtendedKey,
onHideClick = ::onHideClick,
isXpubSupported = isXpubSupported,
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
clickIntents = this@TokenDetailsModel,
),
)
}

View file

@ -18,6 +18,7 @@ internal sealed interface StakingBlockUM {
val cryptoAmount: BigDecimal?,
val fiatAmount: BigDecimal?,
val onStakeClicked: () -> Unit,
val isBetaMode: Boolean = false,
) : StakingBlockUM
data class StakeAvailable(
@ -26,5 +27,6 @@ internal sealed interface StakingBlockUM {
val subtitleText: TextReference,
val isEnabled: Boolean,
val onStakeClicked: () -> Unit,
val isBetaMode: Boolean = false,
) : StakingBlockUM
}

View file

@ -15,6 +15,8 @@ import com.tangem.domain.models.staking.RewardBlockType
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingEntryInfo
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.optionOrNull
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.IconState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.StakingBlockUM
@ -53,12 +55,20 @@ internal class TokenDetailsStakingInfoConverter(
StakingAvailability.TemporaryUnavailable -> StakingBlockUM.TemporaryUnavailable
StakingAvailability.Unavailable -> null
is StakingAvailability.Full -> getStakedBlockOrNull(status)
is StakingAvailability.Available -> getStakingInfoBlock(status, state)
is StakingAvailability.Available -> getStakingInfoBlock(
status = status,
state = state,
isBetaMode = stakingAvailability.optionOrNull?.integrationId == StakingIntegrationID.P2PEthPool,
)
}
}
@Suppress("CyclomaticComplexMethod")
private fun getStakingInfoBlock(status: CryptoCurrencyStatus, state: TokenDetailsState): StakingBlockUM? {
private fun getStakingInfoBlock(
status: CryptoCurrencyStatus,
state: TokenDetailsState,
isBetaMode: Boolean,
): StakingBlockUM? {
val stakingBalance = status.value.stakingBalance as? StakingBalance.Data
val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId)
@ -88,7 +98,12 @@ internal class TokenDetailsStakingInfoConverter(
return when {
stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> {
if (!hasPendingBalances) {
getStakeAvailableState(stakingEntryInfo, iconState, isStakingButtonEnabled(status))
getStakeAvailableState(
stakingEntryInfo = stakingEntryInfo,
iconState = iconState,
isEnabled = isStakingButtonEnabled(status),
isBetaMode = isBetaMode,
)
} else {
getStakedBlockWithFiatAmount(status, pendingAmount, null)
}
@ -165,6 +180,7 @@ internal class TokenDetailsStakingInfoConverter(
stakingEntryInfo: StakingEntryInfo,
iconState: IconState,
isEnabled: Boolean,
isBetaMode: Boolean,
): StakingBlockUM.StakeAvailable {
return StakingBlockUM.StakeAvailable(
titleText = resourceReference(id = R.string.token_details_staking_block_title),
@ -175,6 +191,7 @@ internal class TokenDetailsStakingInfoConverter(
iconState = iconState,
isEnabled = isEnabled,
onStakeClicked = clickIntents::onStakeBannerClick,
isBetaMode = isBetaMode,
)
}
@ -205,6 +222,7 @@ internal class TokenDetailsStakingInfoConverter(
),
rewardValue = getRewardText(status, stakingRewardAmount),
onStakeClicked = clickIntents::onStakeBannerClick,
isBetaMode = status.value.stakingBalance is StakingBalance.Data.P2PEthPool,
)
}

View file

@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
@ -15,9 +16,9 @@ import kotlinx.collections.immutable.toImmutableList
internal class UpdateTopBarMenuTransformer(
private val userWallet: UserWallet,
private val hasDerivations: Boolean,
private val isXPubSupported: Boolean,
private val onGenerateExtendedKey: () -> Unit,
private val onHideClick: () -> Unit,
private val isXpubSupported: Boolean,
private val isDynamicAddressesAvailable: Boolean,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy(
@ -30,12 +31,21 @@ internal class UpdateTopBarMenuTransformer(
persistentListOf()
} else {
buildList {
if (isXPubSupported && hasDerivations) {
if (isDynamicAddressesAvailable) {
add(
TangemDropdownMenuItem(
title = resourceReference(R.string.dynamic_addresses),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = clickIntents::onDynamicAddressesClick,
),
)
}
if (isXpubSupported && hasDerivations) {
add(
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_generate_xpub),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = onGenerateExtendedKey,
onClick = clickIntents::onGenerateExtendedKey,
),
)
}
@ -43,7 +53,7 @@ internal class UpdateTopBarMenuTransformer(
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_hide_token),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = onHideClick,
onClick = clickIntents::onHideClick,
),
)
}.toImmutableList()

View file

@ -40,10 +40,8 @@ internal fun StakingBalanceBlock(
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
modifier = Modifier.weight(1f),
) {
Text(
text = stringResourceSafe(R.string.staking_native),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
StakingBalanceTitleRow(
isBetaMode = state.isBetaMode,
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing2),
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) {
@ -84,6 +82,32 @@ internal fun StakingBalanceBlock(
}
}
@Composable
private fun StakingBalanceTitleRow(isBetaMode: Boolean, modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4),
modifier = modifier,
) {
Text(
text = stringResourceSafe(R.string.staking_native),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
if (isBetaMode) {
Text(
text = StringsSigns.DOT,
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
Text(
text = stringResourceSafe(R.string.beta_mode_warning_title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)

View file

@ -33,6 +33,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPre
import com.tangem.feature.tokendetails.presentation.tokendetails.state.StakingBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.CurrencyIcon
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.StringsSigns
/**
* Token staking block
@ -99,12 +100,26 @@ private fun StakingAvailableContent(state: StakingBlockUM.StakeAvailable, modifi
)
SpacerW8()
Column {
Text(
text = state.titleText.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
modifier = Modifier.testTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE),
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
Text(
text = state.titleText.resolveReference(),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
modifier = Modifier.testTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE),
)
if (state.isBetaMode) {
Text(
text = StringsSigns.DOT,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
)
Text(
text = stringResourceSafe(R.string.beta_mode_warning_title),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle2,
)
}
}
Spacer(modifier = Modifier.size(TangemTheme.dimens.size4))

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
@ -29,8 +30,7 @@ class UpdateTopBarMenuTransformerTest {
private val coldWallet: UserWallet.Cold = mockk(relaxed = true)
private val hotWallet: UserWallet.Hot = mockk(relaxed = true)
private val cardTypesResolver: CardTypesResolver = mockk(relaxed = true)
private val onGenerateExtendedKey: () -> Unit = mockk(relaxed = true)
private val onHideClick: () -> Unit = mockk(relaxed = true)
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
@BeforeEach
fun setUp() {
@ -77,8 +77,8 @@ class UpdateTopBarMenuTransformerTest {
assertThat(result.topAppBarUM.menuItems).hasSize(1)
result.topAppBarUM.menuItems.single().onClick()
verify(exactly = 1) { onHideClick.invoke() }
verify(exactly = 0) { onGenerateExtendedKey.invoke() }
verify(exactly = 1) { clickIntents.onHideClick() }
verify(exactly = 0) { clickIntents.onGenerateExtendedKey() }
}
@Test
@ -169,6 +169,48 @@ class UpdateTopBarMenuTransformerTest {
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
}
@Test
fun `GIVEN dynamic addresses available WHEN transform THEN dynamic addresses item is shown first`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = false,
isXPubSupported = false,
isDynamicAddressesAvailable = true,
)
// WHEN
val result = transformer.transform(initialState())
// THEN — Dynamic addresses item first, Hide token second
assertThat(result.topAppBarUM.menuItems).hasSize(2)
result.topAppBarUM.menuItems.first().onClick()
verify(exactly = 1) { clickIntents.onDynamicAddressesClick() }
}
@Test
fun `GIVEN dynamic addresses unavailable WHEN transform THEN dynamic addresses item is hidden`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = false,
isXPubSupported = false,
isDynamicAddressesAvailable = false,
)
// WHEN
val result = transformer.transform(initialState())
// THEN — only Hide token
assertThat(result.topAppBarUM.menuItems).hasSize(1)
result.topAppBarUM.menuItems.single().onClick()
verify(exactly = 0) { clickIntents.onDynamicAddressesClick() }
}
@Test
fun `GIVEN callbacks WHEN menu items invoked THEN callbacks are dispatched`() {
// GIVEN
@ -177,6 +219,7 @@ class UpdateTopBarMenuTransformerTest {
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = true,
isDynamicAddressesAvailable = true,
)
// WHEN
@ -184,20 +227,22 @@ class UpdateTopBarMenuTransformerTest {
result.topAppBarUM.menuItems.forEach { it.onClick() }
// THEN
verify(exactly = 1) { onGenerateExtendedKey.invoke() }
verify(exactly = 1) { onHideClick.invoke() }
verify(exactly = 1) { clickIntents.onDynamicAddressesClick() }
verify(exactly = 1) { clickIntents.onGenerateExtendedKey() }
verify(exactly = 1) { clickIntents.onHideClick() }
}
private fun createTransformer(
userWallet: UserWallet,
hasDerivations: Boolean,
isXPubSupported: Boolean,
isDynamicAddressesAvailable: Boolean = false,
) = UpdateTopBarMenuTransformer(
userWallet = userWallet,
hasDerivations = hasDerivations,
isXPubSupported = isXPubSupported,
onGenerateExtendedKey = onGenerateExtendedKey,
onHideClick = onHideClick,
isXpubSupported = isXPubSupported,
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
clickIntents = clickIntents,
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(

View file

@ -295,11 +295,14 @@ internal class WalletComponent @AssistedInject constructor(
var headerSize by remember { mutableStateOf(0.dp) }
val dialog by dialog.subscribeAsState()
val uiState by model.uiState.collectAsStateWithLifecycle()
val promoBannersBlockComponentContentComponent = ComposableContentComponent { promoModifier ->
promoBannersBlockComponent.ContentWithPadding(modifier = promoModifier, horizontalItemPadding = 12.dp)
}
if (designFeatureToggles.isRedesignEnabled) {
WalletScreen2(
state = uiState,
promoBannersBlockComponent = promoBannersBlockComponent,
promoBannersBlockComponent = promoBannersBlockComponentContentComponent,
tangemPayComponent = tangemPayMainBlockComponent,
virtualAccountComponent = virtualAccountMainBlockComponent,
bottomSheetContent = { onExpandSheet ->
@ -316,7 +319,7 @@ internal class WalletComponent @AssistedInject constructor(
} else {
WalletScreen(
state = uiState,
promoBannersBlockComponent = promoBannersBlockComponent,
promoBannersBlockComponent = promoBannersBlockComponentContentComponent,
tangemPayComponent = tangemPayMainBlockComponent,
virtualAccountComponent = virtualAccountMainBlockComponent,
bottomSheetContent = { onExpandSheet ->

View file

@ -373,7 +373,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
)
if (handleUnavailabilityReason(unavailabilityReason)) return
if (isTopUpBlockedByBackupError(accountId.userWalletId)) return
modelScope.launch(dispatchers.main) {
if (needShowYieldSupplyWarning(cryptoCurrencyStatus)) {
@ -462,7 +461,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
override fun onMultiWalletSwapClick(userWalletId: UserWalletId) {
if (isTopUpBlockedByBackupError(userWalletId)) return
if (!isMultiWalletTokensLoaded()) return
modelScope.launch {

View file

@ -67,7 +67,7 @@ internal fun WalletListContent(
promoBannersBlockComponent?.let { component ->
item(key = "PromoBannersBlock") {
component.Content(modifier = itemModifier)
component.Content(modifier = Modifier.padding(top = TangemTheme.dimens2.x3))
}
}

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "releases-6.0-1580"
tangemBlockchainSdk = "releases-6.0-1587"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "releases-6.0-626"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^

View file

@ -29,6 +29,7 @@ internal fun BaseExtension.configureCompose(project: Project) {
contains(Regex(pattern = ":features:feed:api\$")) || // provides Composable function
contains(Regex(pattern = ":features:manage-tokens:api\$")) || // provides Composable function
contains(Regex(pattern = ":features:txhistory:api\$")) || // provides Composable function
contains(Regex(pattern = ":features:promo-banners:api\$")) || // provides Composable function
contains(Regex(pattern = ":impl\$"))
}