Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 22:16:50 +04:00
commit 216796ed09
27 changed files with 638 additions and 222 deletions

View file

@ -3,19 +3,22 @@ package com.tangem.core.ui.ds.button.action
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.SecondaryTangemButton
@ -32,6 +35,8 @@ import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
private val ACTION_BUTTONS_SPACING = 10.dp
/**
* Action buttons row
*
@ -42,20 +47,53 @@ import kotlinx.collections.immutable.persistentListOf
*/
@Composable
fun ActionButtons(buttons: ImmutableList<TangemButtonUM>, modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
val spacingPx = with(LocalDensity.current) { ACTION_BUTTONS_SPACING.roundToPx() }
Layout(
modifier = modifier,
) {
content = {
buttons.forEachIndexed { index, button ->
key(button.text to index) {
ActionButton(button = button)
}
}
},
) { measurables, constraints ->
if (measurables.isEmpty()) {
return@Layout layout(constraints.minWidth, constraints.minHeight) {}
}
val cellWidth = measurables.maxOf { it.maxIntrinsicWidth(constraints.maxHeight) }
val cellConstraints = Constraints(
minWidth = cellWidth,
maxWidth = cellWidth,
minHeight = 0,
maxHeight = constraints.maxHeight,
)
val placeables = measurables.map { it.measure(cellConstraints) }
val contentWidth = cellWidth * placeables.size + spacingPx * (placeables.size - 1)
val width = if (constraints.hasBoundedWidth) maxOf(constraints.maxWidth, contentWidth) else contentWidth
val height = placeables.maxOf { it.height }
layout(width, height) {
var x = (width - contentWidth) / 2
placeables.forEach { placeable ->
placeable.place(x = x, y = (height - placeable.height) / 2)
x += cellWidth + spacingPx
}
}
}
}
@Composable
private fun ActionButton(button: TangemButtonUM, modifier: Modifier = Modifier) {
val textColor = if (button.isEnabled) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors2.text.status.disabled
}
Column(
modifier = Modifier
modifier = modifier
.padding(horizontal = TangemTheme.dimens2.x2_5)
.testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
.semantics { if (!button.isEnabled) disabled() },
@ -73,12 +111,10 @@ fun ActionButtons(buttons: ImmutableList<TangemButtonUM>, modifier: Modifier = M
text = button.text.orEmpty().resolveReference(),
style = TangemTheme.typography2.subheadlineMedium14,
color = textColor,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
}
}
}
// region Preview

View file

@ -18,7 +18,7 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cloud_24_filled
private const val DEFAULT_DEVICE_ICON_COLOR = 0xFF2C2C2C
private const val DEFAULT_DEVICE_ICON_COLOR = 0xFF595963
private const val DEFAULT_BORDER_COLOR = 0x1A1E1E1E
/**

View file

@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -108,7 +108,7 @@ fun TangemButton(
enabled = isEnabled,
color = backgroundColor,
border = resolveBorder(isFocused = isFocused, colorTokens = colorTokens, contentAlpha = contentAlpha),
shape = RoundedCornerShape(999.dp),
shape = CircleShape,
interactionSource = interactionSource,
isMaterial = variant == TangemButton.Variant.Material,
) {

View file

@ -250,7 +250,7 @@ private fun CloseButton(onClick: () -> Unit) {
@Composable
private fun Preview(@PreviewParameter(TangemSearchStateProvider::class) state: TangemSearch.State) {
TangemThemePreviewRedesign {
Box(modifier = Modifier.background(TangemTheme.colors3.bg.secondary)) {
Box(modifier = Modifier.background(TangemTheme.colors3.bg.tertiary)) {
TangemSearch(
state = state,
modifier = Modifier

View file

@ -12,8 +12,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@ -113,7 +117,7 @@ fun TangemSurface(
@Composable
private fun Modifier.materialShadow(shape: Shape): Modifier = softLayerShadow(
radius = 40.dp,
color = Color.Black.copy(alpha = 0.10f),
color = Color.Black.copy(alpha = 0.12f),
shape = shape,
spread = 0.dp,
offset = DpOffset(x = 0.dp, y = 8.dp),
@ -141,19 +145,22 @@ private fun Modifier.materialBorder(shape: Shape): Modifier = border(
@Composable
private fun Modifier.materialFill(): Modifier {
val isBlurEnabled = LocalHazeState.current.blurEnabled
val material = TangemTheme.colors3.material
val hazed = hazeEffectTangem(
style = HazeStyle(
backgroundColor = TangemTheme.colors3.material.fill.blur,
backgroundColor = Color.Transparent,
blurRadius = 32.dp,
tints = emptyList(),
tints = listOf(
HazeTint(material.fill.blur),
),
),
) {
fallbackTint = HazeTint(Color.Transparent)
}
return hazed.conditionalCompose(!isBlurEnabled) {
// Paint the opaque fill first, then layer the translucent tint on top so both are visible.
background(TangemTheme.colors3.material.fill.solid)
.background(TangemTheme.colors3.material.tint.solid)
background(material.fill.solid)
.background(material.tint.solid)
}
}
@ -162,13 +169,22 @@ private fun Modifier.materialFill(): Modifier {
@ReadOnlyComposable
private fun materialBorderBrush(): Brush {
val border = TangemTheme.colors3.material.border
return Brush.linearGradient(
0f to border.start,
0.5f to border.mid,
1f to border.end,
start = Offset.Zero,
end = Offset.Infinite,
val startColor = border.start
val midColor = Color.Transparent
val endColor = border.end
return object : ShaderBrush() {
override fun createShader(size: Size): Shader {
val w = size.width
val h = size.height
val k = 2f * w * h / (w * w + h * h)
return LinearGradientShader(
from = Offset.Zero,
to = Offset(x = k * h, y = k * w),
colors = listOf(startColor, midColor, midColor, endColor),
colorStops = listOf(0f, 0.40f, 0.60f, 1f),
)
}
}
}
// endregion

View file

@ -47,6 +47,9 @@ private enum class SlotId { Start, Content, Group, End }
* @param contentAlign How [contentColumn] is aligned horizontally within the bar.
* @param windowInsets Top inset applied above the row. Pass `WindowInsets(0)` inside a bottom
* sheet / modal.
* @param contentPadding Inner padding applied to the row, inside [windowInsets]. Defaults to
* [TangemTopNavigation.DefaultContentPadding] (top 8, bottom 16, horizontal 16). Override a single
* edge via [com.tangem.core.ui.extensions.copy], e.g. `DefaultContentPadding.copy(top = 16.dp)`.
* @param blurBackground Whether the fade behind the row should blur the content below.
* @param startButton Leading slot. Typically a back button (see [TangemButton.Back]).
* @param endButtonsGroup Optional pill-grouped secondary actions placed just before [endButton].
@ -59,6 +62,7 @@ fun TangemTopNavigation(
modifier: Modifier = Modifier,
contentAlign: TangemTopNavigation.ContentAlign = TangemTopNavigation.ContentAlign.Start,
windowInsets: WindowInsets = WindowInsets.statusBars,
contentPadding: PaddingValues = TangemTopNavigation.DefaultContentPadding,
blurBackground: Boolean = true,
startButton: (@Composable () -> Unit)? = null,
endButtonsGroup: (@Composable RowScope.() -> Unit)? = null,
@ -89,12 +93,7 @@ fun TangemTopNavigation(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(windowInsets)
.padding(
top = 8.dp,
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
),
.padding(contentPadding),
content = {
val displayedStart = rememberLastNonNull(startButton)
Box(modifier = Modifier.layoutId(SlotId.Start)) {
@ -208,6 +207,7 @@ fun TangemTopNavigation(
subtitle: TextReference? = null,
contentAlign: TangemTopNavigation.ContentAlign = TangemTopNavigation.ContentAlign.Start,
windowInsets: WindowInsets = WindowInsets.statusBars,
contentPadding: PaddingValues = TangemTopNavigation.DefaultContentPadding,
blurBackground: Boolean = true,
onBack: (() -> Unit)? = null,
endButtonsGroup: (@Composable RowScope.() -> Unit)? = null,
@ -217,6 +217,7 @@ fun TangemTopNavigation(
modifier = modifier,
contentAlign = contentAlign,
windowInsets = windowInsets,
contentPadding = contentPadding,
blurBackground = blurBackground,
startButton = onBack?.let { { TangemButton.Back(onClick = it) } },
endButtonsGroup = endButtonsGroup,
@ -233,6 +234,7 @@ fun TangemTopNavigation(
subtitle: TextReference? = null,
contentAlign: TangemTopNavigation.ContentAlign = TangemTopNavigation.ContentAlign.Start,
windowInsets: WindowInsets = WindowInsets.statusBars,
contentPadding: PaddingValues = TangemTopNavigation.DefaultContentPadding,
blurBackground: Boolean = true,
endButtonsGroup: (@Composable RowScope.() -> Unit)? = null,
onClose: (() -> Unit)? = null,
@ -242,6 +244,7 @@ fun TangemTopNavigation(
modifier = modifier,
contentAlign = contentAlign,
windowInsets = windowInsets,
contentPadding = contentPadding,
blurBackground = blurBackground,
startButton = startButton,
endButtonsGroup = endButtonsGroup,
@ -258,6 +261,7 @@ fun TangemTopNavigation(
subtitle: TextReference? = null,
contentAlign: TangemTopNavigation.ContentAlign = TangemTopNavigation.ContentAlign.Start,
windowInsets: WindowInsets = WindowInsets.statusBars,
contentPadding: PaddingValues = TangemTopNavigation.DefaultContentPadding,
blurBackground: Boolean = true,
onBack: (() -> Unit)? = null,
endButton: @Composable () -> Unit,
@ -266,6 +270,7 @@ fun TangemTopNavigation(
modifier = modifier,
contentAlign = contentAlign,
windowInsets = windowInsets,
contentPadding = contentPadding,
blurBackground = blurBackground,
startButton = onBack?.let { { TangemButton.Back(onClick = it) } },
endButton = endButton,
@ -308,7 +313,7 @@ private fun ColumnScope.TitleSubtitle(title: TextReference, subtitle: TextRefere
) {
displayedSubtitle?.let { text ->
Column {
Spacer(Modifier.height(2.dp))
Spacer(Modifier.height(4.dp))
TangemNavigationText(text = text, role = TangemNavigationText.Role.Subtitle)
}
}
@ -317,6 +322,15 @@ private fun ColumnScope.TitleSubtitle(title: TextReference, subtitle: TextRefere
object TangemTopNavigation {
/** Default inner padding of the row: top 8, bottom 16, horizontal 16. */
@Suppress("MagicNumber")
val DefaultContentPadding: PaddingValues = PaddingValues(
top = 8.dp,
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
)
/** Horizontal alignment of the center content slot. */
enum class ContentAlign {
Start, Center

View file

@ -0,0 +1,22 @@
package com.tangem.core.ui.extensions
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
/**
* Returns a copy of this [PaddingValues] with the given edges overridden, leaving the rest unchanged.
*/
@Composable
fun PaddingValues.copy(start: Dp? = null, top: Dp? = null, end: Dp? = null, bottom: Dp? = null): PaddingValues {
val layoutDirection = LocalLayoutDirection.current
return PaddingValues(
start = start ?: calculateStartPadding(layoutDirection),
top = top ?: calculateTopPadding(),
end = end ?: calculateEndPadding(layoutDirection),
bottom = bottom ?: calculateBottomPadding(),
)
}

View file

@ -2,6 +2,7 @@ package com.tangem.features.commonfeatures.api.choosetoken.model
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -22,6 +23,7 @@ data class WalletTabUM(
val count: TextReference?,
val isSelected: Boolean,
val onClick: () -> Unit,
val deviceIcon: DeviceIconUM,
)
@Immutable

View file

@ -1,5 +1,6 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.wallet.UserWallet
@ -7,6 +8,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
@ -30,6 +32,8 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
private val settingContextUseCase: SettingContextUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletIconUMConverter: WalletIconUMConverter,
private val dispatchers: CoroutineDispatcherProvider,
private val selectedWalletUseCase: GetSelectedWalletUseCase,
@Assisted private val modelScope: CoroutineScope,
@ -87,6 +91,7 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor(
onClick = { selectWalletTab(walletId) },
isSelected = selectedWalletId == walletId,
count = searchResultCount,
deviceIcon = walletIconUMConverter.convert(getWalletIconUseCase(wallet)),
)
}
val walletListUM = if (walletsUM.size != 1) {

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.*
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.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
@ -38,6 +39,8 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@ -262,6 +265,13 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
style = TangemTheme.typography2.bodySemibold16,
)
Spacer(modifier = Modifier.width(4.dp))
TangemDeviceIcon(
state = state.deviceIcon,
modifier = Modifier.size(20.dp),
)
val count = state.count
if (count != null) {
Spacer(modifier = Modifier.width(8.dp))
@ -464,24 +474,31 @@ private val wallets
isSelected = true,
onClick = {},
count = null,
deviceIcon = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 1"),
isSelected = true,
onClick = {},
count = stringReference("3"),
count = null,
deviceIcon = DeviceIconUM.Mobile,
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 2"),
isSelected = false,
onClick = {},
count = stringReference("333"),
count = stringReference("3"),
deviceIcon = DeviceIconUM.Ring(),
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 3"),
isSelected = false,
onClick = {},
count = null,
deviceIcon = DeviceIconUM.Mobile,
),
)

View file

@ -55,6 +55,7 @@ sealed interface SwapState {
val isFeeCoverage: Boolean,
val sendingAmount: BigDecimal,
val tronFeeNotificationShowCount: Int,
val isAmountSubtractAvailable: Boolean,
val isSendingAmountLoading: Boolean = false,
val currencyCheck: CryptoCurrencyCheck? = null,
val validationResult: Throwable? = null,

View file

@ -110,9 +110,14 @@ class SwapTransferInteractorImpl @Inject constructor(
fee = warningsFee,
feeCurrencyBalanceAfterTransaction = null,
)
val isAmountSubtractAvailable = isAmountSubtractAvailable(
userWalletId = userWallet.walletId,
currency = fromTokenInfo.swapCurrencyStatus.currency,
fee = fee,
)
val coverageState = getCoverageState(
fromTokenInfo = fromTokenInfo,
userWallet = userWallet,
isAmountSubtractAvailable = isAmountSubtractAvailable,
fee = fee,
currencyCheck = currencyCheck,
)
@ -137,6 +142,7 @@ class SwapTransferInteractorImpl @Inject constructor(
isFeeCoverage = coverageState.isFeeCoverage,
sendingAmount = coverageState.sendingAmount,
tronFeeNotificationShowCount = tronFeeNotificationShowCount,
isAmountSubtractAvailable = isAmountSubtractAvailable,
isSendingAmountLoading = coverageState.isSendingAmountLoading,
currencyCheck = currencyCheck,
)
@ -156,18 +162,13 @@ class SwapTransferInteractorImpl @Inject constructor(
).getOrNull()
}
private suspend fun getCoverageState(
private fun getCoverageState(
fromTokenInfo: TokenSwapInfo,
userWallet: UserWallet,
isAmountSubtractAvailable: Boolean,
fee: Fee?,
currencyCheck: CryptoCurrencyCheck,
): CoverageState {
val swapCurrencyStatus = fromTokenInfo.swapCurrencyStatus
val isAmountSubtractAvailable = isAmountSubtractAvailable(
userWalletId = userWallet.walletId,
currency = swapCurrencyStatus.currency,
fee = fee,
)
val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO
val reduceAmountBy = currencyCheck.existentialDeposit.orZero()
val amount = fromTokenInfo.tokenAmount

View file

@ -187,6 +187,7 @@ internal class SwapTransferInteractorImplTest {
isFeeCoverage = false,
sendingAmount = expectedAmount,
tronFeeNotificationShowCount = 0,
isAmountSubtractAvailable = false,
currencyCheck = currencyCheck,
)
assertThat(result).isEqualTo(expected)
@ -259,6 +260,7 @@ internal class SwapTransferInteractorImplTest {
isFeeCoverage = false,
sendingAmount = expectedAmount,
tronFeeNotificationShowCount = 0,
isAmountSubtractAvailable = false,
currencyCheck = currencyCheck,
)
assertThat(result).isEqualTo(expected)

View file

@ -824,8 +824,7 @@ internal class SwapModel @Inject constructor(
transferState = swapState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error,
feeSelectorUM = feeSelectorRepository.state.value,
)
when {
uiState.successState != null -> Unit
@ -873,7 +872,7 @@ internal class SwapModel @Inject constructor(
feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency,
fee = fee,
isTangemPayWithdrawal = isTangemPayWithdrawal(),
feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error,
feeSelectorUM = feeSelectorRepository.state.value,
)
}
}

View file

@ -1,8 +1,8 @@
package com.tangem.feature.swap.ui.transfer
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
@ -18,6 +18,8 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
import com.tangem.lib.crypto.BlockchainUtils.isTezos
@ -33,22 +35,30 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
@Suppress("LongParameterList")
fun getNotifications(
transferState: SwapState.Transfer,
feeSelectorUM: FeeSelectorUM?,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
actions: UiActions,
getFeeError: GetFeeError?,
): ImmutableList<NotificationUM> {
// The fee selector exposes a single sealed state; narrow it here so call sites pass the raw
// FeeSelectorUM and this factory owns the Content/Error/Loading discrimination.
val feeContent = feeSelectorUM
val getFeeError = (feeSelectorUM as? FeeSelectorUM.Error)?.error
return buildList {
maybeAddRentExemptionError(transferState)
maybeAddDomainWarnings(
state = transferState,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fee = fee,
feeSelectorUM = feeContent,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
maybeAddNeedReserveToCreateAccountWarning(transferState)
maybeAddExceedsBalanceNotification(transferState, onBuyClick = actions.openTokenDetailsScreen)
maybeAddExceedsBalanceNotifications(
transferState = transferState,
feeSelectorUM = feeContent,
onBuyClick = actions.openTokenDetailsScreen,
)
maybeAddTooHighOrTooLowNotification(feeContent)
addTronNetworkFeesNotification(
cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
transferState = transferState,
@ -71,13 +81,14 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
state: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
feeSelectorUM: FeeSelectorUM?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
) {
val swapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus
val amount = state.fromTokenInfo.tokenAmount
val balance = swapCurrencyStatus.status.value.amount ?: BigDecimal.ZERO
val fee = (feeSelectorUM as? FeeSelectorUM.Content)?.selectedFeeItem?.fee
val feeValue = fee?.amount?.value.orZero()
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
addExistentialWarningNotification(
@ -191,8 +202,9 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
}
}
private fun MutableList<NotificationUM>.maybeAddExceedsBalanceNotification(
private fun MutableList<NotificationUM>.maybeAddExceedsBalanceNotifications(
transferState: SwapState.Transfer,
feeSelectorUM: FeeSelectorUM?,
onBuyClick: (CryptoCurrency) -> Unit,
) {
val cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status
@ -206,6 +218,27 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
onAnalyticsEvent = {},
onResetAnalyticsEvent = {},
)
val feeAmount = (feeSelectorUM as? FeeSelectorUM.Content)?.selectedFeeItem?.fee?.amount?.value
if (feeAmount != null) {
addExceedBalanceNotification(
feeAmount = feeAmount,
sendingAmount = transferState.sendingAmount,
isSubtractionAvailable = transferState.isAmountSubtractAvailable,
cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
)
}
}
@Suppress("CanBeNonNullable")
private fun MutableList<NotificationUM>.maybeAddTooHighOrTooLowNotification(feeSelectorUM: FeeSelectorUM?) {
val content = feeSelectorUM as? FeeSelectorUM.Content ?: return
val (isFeeTooHigh, diff) = FeeCalculationUtils.checkIfCustomFeeTooHigh(feeSelectorUM = content)
if (isFeeTooHigh) {
add(NotificationUM.Warning.TooHigh(diff))
}
if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM = content)) {
add(NotificationUM.Warning.FeeTooLow)
}
}
private fun MutableList<NotificationUM>.addTronNetworkFeesNotification(

View file

@ -59,8 +59,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
transferState: SwapState.Transfer,
uiStateHolder: SwapStateHolder,
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
feeError: FeeSelectorUM.Error?,
feeSelectorUM: FeeSelectorUM?,
): SwapStateHolder {
val fromTokenSwapInfo = transferState.fromTokenInfo
val isInsufficientBalance = transferState.isInsufficientBalance
@ -68,10 +67,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
val prevAmountField = prevSendCard?.amountField
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeSelectorUM = feeSelectorUM,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
actions = actions,
getFeeError = feeError?.error,
)
return uiStateHolder.copy(
sendCardData = createSendSwapCardState(
@ -344,14 +342,13 @@ internal class SwapTransferStateBuilder @Inject constructor(
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
isTangemPayWithdrawal: Boolean,
feeError: FeeSelectorUM.Error?,
feeSelectorUM: FeeSelectorUM?,
): SwapStateHolder {
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
feeSelectorUM = feeSelectorUM,
actions = actions,
getFeeError = feeError?.error,
)
return uiStateHolder.copy(
notifications = notifications,

View file

@ -2,6 +2,7 @@ package com.tangem.feature.swap.ui.transfer
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
@ -18,8 +19,12 @@ import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem
import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@ -43,10 +48,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result).isEmpty()
@ -65,10 +69,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Solana.RentInfo>()).hasSize(1)
@ -85,16 +88,13 @@ internal class SwapTransferNotificationsFactoryTest {
),
currencyCheck = buildCurrencyCheck(existentialDeposit = BigDecimal("0.5")),
)
val fee: Fee = mockk(relaxed = true) {
every { amount.value } returns BigDecimal("0.4")
}
val feeSelectorUM = contentWithFee(feeValue = BigDecimal("0.4"))
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = feeSelectorUM,
feeCryptoCurrencyStatus = null,
fee = fee,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Error.ExistentialDeposit>()).hasSize(1)
@ -113,10 +113,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Error.MinimumAmountError>()).hasSize(1)
@ -131,10 +130,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Cardano.MinAdaValueCharged>()).hasSize(1)
@ -154,10 +152,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeCoverageNotification>()).hasSize(1)
@ -176,10 +173,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
val reserve = result.filterIsInstance<SwapNotificationUM.Warning.NeedReserveToCreateAccount>()
@ -199,10 +195,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<SwapNotificationUM.Warning.ReduceAmount>()).hasSize(1)
@ -220,10 +215,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Error.TokenExceedsBalance>()).hasSize(1)
@ -242,10 +236,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).hasSize(1)
@ -264,10 +257,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).isEmpty()
@ -285,10 +277,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).isEmpty()
@ -302,10 +293,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = errorSelector(GetFeeError.UnknownError),
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fee = null,
actions = actions,
getFeeError = GetFeeError.UnknownError,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).hasSize(1)
@ -319,10 +309,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = errorSelector(GetFeeError.BlockchainErrors.TronActivationError),
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
fee = null,
actions = actions,
getFeeError = GetFeeError.BlockchainErrors.TronActivationError,
)
val notifications = result.filterIsInstance<NotificationUM.Warning.TronAccountNotActivated>()
@ -337,10 +326,9 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = errorSelector(GetFeeError.UnknownError),
feeCryptoCurrencyStatus = null,
fee = null,
actions = actions,
getFeeError = GetFeeError.UnknownError,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).isEmpty()
@ -353,15 +341,94 @@ internal class SwapTransferNotificationsFactoryTest {
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = null,
feeCryptoCurrencyStatus = buildCoinStatus().status,
fee = null,
actions = actions,
getFeeError = null,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).isEmpty()
}
@Test
fun `GIVEN custom fee below network minimum WHEN getNotifications THEN FeeTooLow is added`() = runTest {
val transferState = buildTransferState()
val feeSelectorUM = contentWithCustomFeeBelowMinimum(
customFeeValue = "0.0001",
minimumFeeValue = BigDecimal("0.001"),
)
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = feeSelectorUM,
feeCryptoCurrencyStatus = null,
actions = actions,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeTooLow>()).hasSize(1)
}
@Test
fun `GIVEN custom fee at network minimum WHEN getNotifications THEN no FeeTooLow`() = runTest {
val transferState = buildTransferState()
val feeSelectorUM = contentWithCustomFeeBelowMinimum(
customFeeValue = "0.001",
minimumFeeValue = BigDecimal("0.001"),
)
val result = sut.getNotifications(
transferState = transferState,
feeSelectorUM = feeSelectorUM,
feeCryptoCurrencyStatus = null,
actions = actions,
)
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeTooLow>()).isEmpty()
}
/**
* Builds a [FeeSelectorUM.Content] with a Custom fee whose [customFeeValue] is below the choosable
* [minimumFeeValue], so
* [com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkIfCustomFeeTooLow]
* reports the fee as too low. The choosable `priority` is left unstubbed (null) so the sibling
* `checkIfCustomFeeTooHigh` short-circuits and does not add a spurious TooHigh notification.
*/
private fun contentWithCustomFeeBelowMinimum(
customFeeValue: String,
minimumFeeValue: BigDecimal,
decimals: Int = 8,
): FeeSelectorUM.Content {
val customField: CustomFeeFieldUM = mockk(relaxed = true) {
every { value } returns customFeeValue
every { this@mockk.decimals } returns decimals
}
val customFeeItem: FeeItem.Custom = mockk(relaxed = true) {
every { customValues } returns persistentListOf(customField)
}
val choosableFees: TransactionFee.Choosable = mockk(relaxed = true) {
every { minimum.amount.value } returns minimumFeeValue
}
return mockk(relaxed = true) {
every { selectedFeeItem } returns customFeeItem
every { fees } returns choosableFees
}
}
/**
* Builds a [FeeSelectorUM.Content] whose selected fee carries [feeValue]. A non-Custom fee item is used so
* [com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkIfCustomFeeTooHigh]
* short-circuits and does not add a spurious TooHigh notification.
*/
private fun contentWithFee(feeValue: BigDecimal): FeeSelectorUM.Content {
val fee: Fee = mockk(relaxed = true) {
every { amount.value } returns feeValue
}
return mockk(relaxed = true) {
every { selectedFeeItem } returns FeeItem.Market(fee = fee)
}
}
private fun errorSelector(error: GetFeeError): FeeSelectorUM.Error = FeeSelectorUM.Error(error = error)
@Suppress("LongParameterList")
private fun buildTransferState(
fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
@ -373,6 +440,7 @@ internal class SwapTransferNotificationsFactoryTest {
isFeeCoverage: Boolean = false,
sendingAmount: BigDecimal = fromTokenInfo.tokenAmount.value,
tronFeeNotificationShowCount: Int = 0,
isAmountSubtractAvailable: Boolean = false,
): SwapState.Transfer = SwapState.Transfer(
userWallet = coldWallet,
fromTokenInfo = fromTokenInfo,
@ -385,6 +453,7 @@ internal class SwapTransferNotificationsFactoryTest {
isFeeCoverage = isFeeCoverage,
sendingAmount = sendingAmount,
tronFeeNotificationShowCount = tronFeeNotificationShowCount,
isAmountSubtractAvailable = isAmountSubtractAvailable,
currencyCheck = currencyCheck,
validationResult = validationResult,
minAdaValue = minAdaValue,

View file

@ -39,12 +39,14 @@ import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.presentation.R
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@ -57,10 +59,9 @@ internal class SwapTransferStateBuilderTest {
coEvery {
getNotifications(
transferState = any(),
feeSelectorUM = any(),
feeCryptoCurrencyStatus = any(),
fee = any(),
actions = any(),
getFeeError = any(),
)
} returns persistentListOf()
}
@ -72,6 +73,21 @@ internal class SwapTransferStateBuilderTest {
isFeeApproximateUseCase = isFeeApproximateUseCase,
)
// PER_CLASS reuses the notificationsFactory mock across tests, so clear its recorded calls (and re-stub)
// before each test to keep coVerify(exactly = 1) scoped to the current test.
@BeforeEach
fun resetMocks() {
clearMocks(notificationsFactory)
coEvery {
notificationsFactory.getNotifications(
transferState = any(),
feeSelectorUM = any(),
feeCryptoCurrencyStatus = any(),
actions = any(),
)
} returns persistentListOf()
}
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
@ -123,8 +139,7 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
feeSelectorUM = null,
)
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
@ -154,10 +169,9 @@ internal class SwapTransferStateBuilderTest {
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeSelectorUM = any(),
feeCryptoCurrencyStatus = null,
fee = null,
actions = any(),
getFeeError = any(),
)
}
}
@ -177,8 +191,7 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
feeSelectorUM = null,
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
@ -197,10 +210,9 @@ internal class SwapTransferStateBuilderTest {
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeSelectorUM = any(),
feeCryptoCurrencyStatus = null,
fee = null,
actions = any(),
getFeeError = any(),
)
}
}
@ -221,8 +233,7 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
feeSelectorUM = null,
)
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
@ -241,10 +252,9 @@ internal class SwapTransferStateBuilderTest {
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeSelectorUM = any(),
feeCryptoCurrencyStatus = null,
fee = null,
actions = any(),
getFeeError = any(),
)
}
}
@ -265,8 +275,7 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
feeSelectorUM = null,
)
val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio
@ -291,10 +300,9 @@ internal class SwapTransferStateBuilderTest {
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeSelectorUM = any(),
feeCryptoCurrencyStatus = null,
fee = null,
actions = any(),
getFeeError = any(),
)
}
}
@ -338,10 +346,9 @@ internal class SwapTransferStateBuilderTest {
coEvery {
notificationsFactory.getNotifications(
transferState = transferState,
feeSelectorUM = any(),
feeCryptoCurrencyStatus = null,
fee = fee,
actions = any(),
getFeeError = any(),
)
} returns persistentListOf()
@ -353,7 +360,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
feeError = null,
feeSelectorUM = null,
)
assertThat(result.swapButton.isEnabled).isTrue()
@ -362,10 +369,9 @@ internal class SwapTransferStateBuilderTest {
coVerify(exactly = 1) {
notificationsFactory.getNotifications(
transferState = transferState,
feeSelectorUM = any(),
feeCryptoCurrencyStatus = null,
fee = fee,
actions = any(),
getFeeError = any(),
)
}
}
@ -387,8 +393,7 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = mockk(relaxed = true),
feeError = null,
feeSelectorUM = null,
)
val sendCard = result.sendCardData as SwapCardState.SwapCardData
@ -422,8 +427,7 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
feeSelectorUM = null,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
@ -450,8 +454,7 @@ internal class SwapTransferStateBuilderTest {
transferState = transferState,
uiStateHolder = baseStateHolder(),
feePaidCryptoCurrencyStatus = null,
fee = null,
feeError = null,
feeSelectorUM = null,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
@ -482,7 +485,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = mockk(relaxed = true),
isTangemPayWithdrawal = false,
feeError = null,
feeSelectorUM = null,
)
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
@ -517,7 +520,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
feeError = null,
feeSelectorUM = null,
)
assertThat(result.transferFooter).isInstanceOf(TextReference.Combined::class.java)
@ -576,7 +579,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
feeError = null,
feeSelectorUM = null,
)
assertThat(result.transferFooter).isEqualTo(
@ -622,7 +625,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
feeError = null,
feeSelectorUM = null,
)
assertThat(result.transferFooter).isEqualTo(
@ -753,7 +756,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = null,
isTangemPayWithdrawal = true,
feeError = null,
feeSelectorUM = null,
)
assertThat(result.swapButton.isEnabled).isTrue()
@ -789,7 +792,7 @@ internal class SwapTransferStateBuilderTest {
feePaidCryptoCurrencyStatus = null,
fee = null,
isTangemPayWithdrawal = false,
feeError = null,
feeSelectorUM = null,
)
assertThat(result.swapButton.isEnabled).isFalse()
@ -939,6 +942,7 @@ internal class SwapTransferStateBuilderTest {
isFeeCoverage = isFeeCoverage,
sendingAmount = toAmount,
tronFeeNotificationShowCount = 0,
isAmountSubtractAvailable = false,
isSendingAmountLoading = isSendingAmountLoading,
)
}

View file

@ -262,8 +262,6 @@ private fun SubtitleLimit(state: TangemPayDailyLimitBlockState, modifier: Modifi
text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_error_subtitle),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
is TangemPayDailyLimitBlockState.Content -> {
@ -272,8 +270,6 @@ private fun SubtitleLimit(state: TangemPayDailyLimitBlockState, modifier: Modifi
text = state.limit,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
TangemPayDailyLimitBlockState.Loading -> {

View file

@ -137,7 +137,7 @@ private fun ComponentPreview(state: TangemButtonStory) {
background = state.background,
modifier = Modifier
.matchParentSize()
.hazeSourceTangem(zIndex = 0f),
.hazeSourceTangem(zIndex = -1f),
)
Box(
contentAlignment = Alignment.Center,

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
@ -76,7 +77,12 @@ private fun ComponentPreview(state: TangemSearchStory) {
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp)),
) {
PreviewBackground(background = state.background, modifier = Modifier.matchParentSize())
PreviewBackground(
background = state.background,
modifier = Modifier
.matchParentSize()
.hazeSourceTangem(),
)
TangemSearch(
state = TangemSearch.State(
placeholderText = stringReference(state.placeholder.text),

View file

@ -10,10 +10,12 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.child.managetokens.model.AddAndManageModel
import com.tangem.feature.wallet.child.managetokens.ui.AddAndManageBottomSheetContent
import com.tangem.feature.wallet.child.managetokens.ui.AddAndManageBottomSheetContentLegacy
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import kotlinx.serialization.builtins.serializer
@ -51,12 +53,21 @@ internal class AddAndManageBottomSheetComponent(
val portfolioSelectorSlot by portfolioSelectorSlot.subscribeAsState()
val state by model.state.collectAsStateWithLifecycle()
if (LocalRedesignEnabled.current) {
AddAndManageBottomSheetContent(
onAddTokensClick = model::onAddTokensClick,
shouldShowOrganizeButton = state.shouldShowOrganize,
onOrganizeTokensClick = model::onOrganizeTokensClick,
onDismiss = ::dismiss,
)
} else {
AddAndManageBottomSheetContentLegacy(
onAddTokensClick = model::onAddTokensClick,
shouldShowOrganizeButton = state.shouldShowOrganize,
onOrganizeTokensClick = model::onOrganizeTokensClick,
onDismiss = ::dismiss,
)
}
portfolioSelectorSlot.child?.instance?.BottomSheet()
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
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
@ -12,21 +13,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.res.R as ResR
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_chevron_right_24
import com.tangem.feature.wallet.impl.R
@Composable
internal fun AddAndManageBottomSheetContent(
@ -35,24 +40,31 @@ internal fun AddAndManageBottomSheetContent(
onOrganizeTokensClick: () -> Unit,
onDismiss: () -> Unit,
) {
val config = TangemBottomSheetConfig(
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = AddAndManageBottomSheetConfigContent,
)
TangemModalBottomSheet<AddAndManageBottomSheetConfigContent>(
config = config,
containerColor = TangemTheme.colors.background.primary,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors2.surface.level2,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(ResR.string.main_add_and_manage_tokens),
endIconRes = R.drawable.ic_close_24,
onEndClick = onDismiss,
TangemTopBar(
title = resourceReference(R.string.main_add_and_manage_tokens),
type = TangemTopBarType.BottomSheet,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = onDismiss,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
},
content = {
AddAndManageContent(
modifier = Modifier.padding(bottom = 16.dp),
onAddTokensClick = onAddTokensClick,
shouldShowOrganizeButton = shouldShowOrganizeButton,
onOrganizeTokensClick = onOrganizeTokensClick,
@ -66,38 +78,29 @@ private fun AddAndManageContent(
onAddTokensClick: () -> Unit,
shouldShowOrganizeButton: Boolean,
onOrganizeTokensClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
modifier = modifier
.fillMaxWidth()
.padding(
vertical = 8.dp,
horizontal = 16.dp,
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
AddAndManageRow(
iconRes = R.drawable.ic_plus_24,
title = ResR.string.add_and_manage_sheet_manage_title,
subtitle = ResR.string.add_and_manage_sheet_manage_subtitle,
title = R.string.add_and_manage_sheet_manage_title,
subtitle = R.string.add_and_manage_sheet_manage_subtitle,
onClick = onAddTokensClick,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = if (shouldShowOrganizeButton) 1 else 0,
addDefaultPadding = false,
backgroundColor = TangemTheme.colors.background.action,
),
)
if (shouldShowOrganizeButton) {
AddAndManageRow(
iconRes = R.drawable.ic_filter_default_24,
title = ResR.string.add_and_manage_sheet_organize_title,
subtitle = ResR.string.add_and_manage_sheet_organize_subtitle,
title = R.string.add_and_manage_sheet_organize_title,
subtitle = R.string.add_and_manage_sheet_organize_subtitle,
onClick = onOrganizeTokensClick,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = 1,
lastIndex = 1,
addDefaultPadding = false,
backgroundColor = TangemTheme.colors.background.action,
),
)
}
}
@ -114,50 +117,57 @@ private fun AddAndManageRow(
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 15.dp),
.background(TangemTheme.colors2.surface.level3)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.size(40.dp)
.clip(CircleShape)
.background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f)),
.background(TangemTheme.colors2.graphic.status.accent.copy(alpha = 0.1f)),
) {
Icon(
modifier = Modifier.size(18.dp),
painter = rememberVectorPainter(ImageVector.vectorResource(id = iconRes)),
tint = TangemTheme.colors.icon.accent,
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(id = iconRes),
tint = TangemTheme.colors2.markers.iconBlue,
contentDescription = null,
)
}
SpacerW(12.dp)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = stringResourceSafe(id = title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors2.text.neutral.primary,
)
Text(
text = stringResourceSafe(id = subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
SpacerW(8.dp)
Icon(
imageVector = Icons.ic_chevron_right_24,
tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant,
contentDescription = null,
)
}
}
private object AddAndManageBottomSheetConfigContent : TangemBottomSheetConfigContent
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun AddAndManageBottomSheetContent_Preview() {
TangemThemePreview {
TangemThemePreviewRedesign {
AddAndManageContent(
onAddTokensClick = {},
shouldShowOrganizeButton = true,

View file

@ -0,0 +1,168 @@
package com.tangem.feature.wallet.child.managetokens.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.res.R as ResR
@Composable
internal fun AddAndManageBottomSheetContentLegacy(
onAddTokensClick: () -> Unit,
shouldShowOrganizeButton: Boolean,
onOrganizeTokensClick: () -> Unit,
onDismiss: () -> Unit,
) {
val config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = AddAndManageBottomSheetConfigContent,
)
TangemModalBottomSheet<AddAndManageBottomSheetConfigContent>(
config = config,
containerColor = TangemTheme.colors.background.primary,
title = {
TangemModalBottomSheetTitle(
title = resourceReference(ResR.string.main_add_and_manage_tokens),
endIconRes = R.drawable.ic_close_24,
onEndClick = onDismiss,
)
},
content = {
AddAndManageContent(
onAddTokensClick = onAddTokensClick,
shouldShowOrganizeButton = shouldShowOrganizeButton,
onOrganizeTokensClick = onOrganizeTokensClick,
)
},
)
}
@Composable
private fun AddAndManageContent(
onAddTokensClick: () -> Unit,
shouldShowOrganizeButton: Boolean,
onOrganizeTokensClick: () -> Unit,
) {
Column(
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
) {
AddAndManageRow(
iconRes = R.drawable.ic_plus_24,
title = ResR.string.add_and_manage_sheet_manage_title,
subtitle = ResR.string.add_and_manage_sheet_manage_subtitle,
onClick = onAddTokensClick,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = if (shouldShowOrganizeButton) 1 else 0,
addDefaultPadding = false,
backgroundColor = TangemTheme.colors.background.action,
),
)
if (shouldShowOrganizeButton) {
AddAndManageRow(
iconRes = R.drawable.ic_filter_default_24,
title = ResR.string.add_and_manage_sheet_organize_title,
subtitle = ResR.string.add_and_manage_sheet_organize_subtitle,
onClick = onOrganizeTokensClick,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = 1,
lastIndex = 1,
addDefaultPadding = false,
backgroundColor = TangemTheme.colors.background.action,
),
)
}
}
}
@Composable
private fun AddAndManageRow(
iconRes: Int,
title: Int,
subtitle: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f)),
) {
Icon(
modifier = Modifier.size(18.dp),
painter = rememberVectorPainter(ImageVector.vectorResource(id = iconRes)),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = stringResourceSafe(id = title),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = stringResourceSafe(id = subtitle),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
private object AddAndManageBottomSheetConfigContent : TangemBottomSheetConfigContent
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun AddAndManageBottomSheetContent_Preview() {
TangemThemePreview {
AddAndManageContent(
onAddTokensClick = {},
shouldShowOrganizeButton = true,
onOrganizeTokensClick = {},
)
}
}
// endregion

View file

@ -1,6 +1,9 @@
package com.tangem.feature.wallet.child.organizetokens.model.converter.items
import com.tangem.common.ui.account.AccountIconItemStateConverter
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
@ -20,6 +23,10 @@ internal class OrganizeAccountItemConverter(
return OrganizeRowItemUM.Portfolio(
headerRowUM = TangemHeaderRowUM(
id = value.accountId.value,
startIconUM = TangemIconUM.Currency(
currencyIconState = AccountIconItemStateConverter(size = AccountIconSize.RedesignExtraSmall)
.convert(value.account),
),
title = value.account.accountName.toUM().value,
subtitle = stringReference(
accountBalance?.amount.format {

View file

@ -99,7 +99,7 @@ internal fun WalletBalance(
}
}
SpacerH(TangemTheme.dimens2.x2)
ActionButtons(buttons)
ActionButtons(buttons, modifier = Modifier.fillMaxWidth())
SpacerH(TangemTheme.dimens2.x6)
}
}

View file

@ -5,7 +5,7 @@
# https://github.com/tangem/tangem-sdk-android/
# https://github.com/tangem/vico
tangemBlockchainSdk = "develop-1586"
tangemBlockchainSdk = "develop-1592"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "develop-630"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^