Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-09 16:18:34 +04:00
commit 997194b3fb
92 changed files with 2454 additions and 391 deletions

View file

@ -18,6 +18,7 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTi
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.impl.R
@ -69,7 +70,7 @@ internal fun AddToPortfolioBottomSheet(
AddToPortfolioRoutes.AddToken,
AddToPortfolioRoutes.Empty,
is AddToPortfolioRoutes.NetworkSelector,
AddToPortfolioRoutes.TokenActions,
is AddToPortfolioRoutes.TokenActions,
-> true
}
if (isScrollableContent) {
@ -92,11 +93,12 @@ private fun AddToPortfolioBottomSheetTitle(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title: TextReference = when (stack.active.configuration) {
val title: TextReference = when (val config = stack.active.configuration) {
AddToPortfolioRoutes.AddToken -> resourceReference(R.string.common_add_token)
AddToPortfolioRoutes.Empty -> TextReference.EMPTY
is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network)
AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token)
is AddToPortfolioRoutes.TokenActions ->
resourceReference(R.string.get_token_title, wrappedList(config.currencyName))
AddToPortfolioRoutes.UserPortfolio -> resourceReference(R.string.markets_portfolio_block_title)
AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent)
.title.collectAsStateWithLifecycle().value

View file

@ -104,7 +104,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor(
): ComposableContentComponent = when (config) {
AddToPortfolioRoutes.AddToken -> addTokenComponent
AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent
AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
is AddToPortfolioRoutes.TokenActions -> tokenActionsComponent
AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY
AddToPortfolioRoutes.UserPortfolio -> createUserPortfolioComponent(componentContext)
is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create(

View file

@ -305,7 +305,9 @@ internal class AddToPortfolioModel @Inject constructor(
setupTokenActionsFlow(selectedPortfolioSnapshot, addedToken)
.onEach { cryptoCurrencyData ->
tokenActionsData.emit(cryptoCurrencyData)
navigation.replaceAll(AddToPortfolioRoutes.TokenActions)
navigation.replaceAll(
AddToPortfolioRoutes.TokenActions(cryptoCurrencyData.status.currency.name),
)
}
.onEmpty { finishSuccessFlow(result) }
.launchIn(this)

View file

@ -2,6 +2,7 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.commonfeatures.impl.R
internal data class AddToPortfolioRouteUiSpec(
@ -42,8 +43,8 @@ internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (th
shouldApplyHorizontalPadding = false,
footer = AddToPortfolioFooterKind.UserPortfolioAdd,
)
AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.common_get_token),
is AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec(
title = resourceReference(R.string.get_token_title, wrappedList(currencyName)),
isScrollable = false,
shouldApplyHorizontalPadding = true,
footer = AddToPortfolioFooterKind.None,

View file

@ -27,5 +27,5 @@ internal sealed interface AddToPortfolioRoutes : Route {
data object UserPortfolio : AddToPortfolioRoutes
@Serializable
data object TokenActions : AddToPortfolioRoutes
data class TokenActions(val currencyName: String) : AddToPortfolioRoutes
}

View file

@ -17,18 +17,23 @@ import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent
import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress
import com.tangem.features.wallet.utils.UserWalletImageFetcher
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.operations.attestation.ArtworkSize
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ModelScoped
@Suppress("LongParameterList")
@OptIn(ExperimentalCoroutinesApi::class)
internal class AddTokenModel @Inject constructor(
paramsContainer: ParamsContainer,
private val walletImageFetcher: UserWalletImageFetcher,
private val uiBuilder: AddTokenUiBuilder,
private val messageSender: UiMessageSender,
private val coldWalletAndHasMissedDerivationsUseCase: ColdWalletAndHasMissedDerivationsUseCase,
@ -46,15 +51,22 @@ internal class AddTokenModel @Inject constructor(
field = MutableStateFlow(value = null)
init {
val walletImageFlow = params.selectedPortfolio
.map { it.userWallet }
.distinctUntilChanged()
.flatMapLatest { walletImageFetcher.walletImage(it, ArtworkSize.SMALL) }
combine(
flow = params.selectedNetwork.distinctUntilChanged(),
flow2 = params.selectedPortfolio.distinctUntilChanged(),
transform = { selectedNetwork, selectedPortfolio ->
flow3 = walletImageFlow,
transform = { selectedNetwork, selectedPortfolio, walletImage ->
addTokenJob.join()
val isTangemIconVisible = needColdWalletInteraction(selectedNetwork, selectedPortfolio)
uiBuilder.updateContent(
selectedPortfolio = selectedPortfolio,
selectedNetwork = selectedNetwork,
walletImage = walletImage,
isTangemIconVisible = isTangemIconVisible,
onConfirmClick = { onAddClick(selectedNetwork, selectedPortfolio).saveIn(addTokenJob) },
)

View file

@ -5,6 +5,7 @@ import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.PortfolioSelectUM
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.addtoken.AddTokenUM
import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
@ -35,13 +36,18 @@ internal class AddTokenUiBuilder @Inject constructor(
)
}
private fun createPortfolio(selectedPortfolio: SelectedPortfolio): PortfolioSelectUM {
private fun createPortfolio(
selectedPortfolio: SelectedPortfolio,
walletImage: UserWalletItemUM.ImageState,
): PortfolioSelectUM {
val accountIcon: AccountIconUM?
val portfolioName: TextReference
val imageState: UserWalletItemUM.ImageState?
when (selectedPortfolio.isAccountMode) {
false -> {
accountIcon = null
portfolioName = stringReference(selectedPortfolio.userWallet.name)
imageState = walletImage
}
true -> {
val accountStatus = selectedPortfolio.account.account
@ -50,6 +56,7 @@ internal class AddTokenUiBuilder @Inject constructor(
is CryptoPortfolio -> CryptoPortfolioIconConverter.convert(accountStatus.account.icon)
is Payment -> AccountIconUM.Payment
}
imageState = null
}
}
return PortfolioSelectUM(
@ -58,12 +65,14 @@ internal class AddTokenUiBuilder @Inject constructor(
isAccountMode = selectedPortfolio.isAccountMode,
isMultiChoice = selectedPortfolio.isAvailableMorePortfolio,
onClick = { params.callbacks.onChangePortfolioClick() },
imageState = imageState,
)
}
fun updateContent(
selectedPortfolio: SelectedPortfolio,
selectedNetwork: SelectedNetwork,
walletImage: UserWalletItemUM.ImageState,
isTangemIconVisible: Boolean,
onConfirmClick: () -> Unit,
): AddTokenUM {
@ -78,7 +87,7 @@ internal class AddTokenUiBuilder @Inject constructor(
onConfirmClick = onConfirmClick,
)
val networkUM = createNetwork(selectedNetwork)
val portfolioUM = createPortfolio(selectedPortfolio)
val portfolioUM = createPortfolio(selectedPortfolio, walletImage)
val currency = selectedNetwork.cryptoCurrency
val tokenToAdd = TokenItemState.Content(
id = currency.id.value,

View file

@ -8,7 +8,6 @@ import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems
import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.IconTint
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
@ -55,12 +54,11 @@ internal class ChooseTokenListItemConverter(
}
private val fiatAmountStateProvider: ((TotalFiatBalance, isExpanded: Boolean) -> FiatAmountState?) =
{ totalBalance, isExpanded ->
when {
isSearchingState -> FiatAmountState.Empty
!isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative)
else -> AccountCryptoPortfolioItemStateConverter
.createFiatAmountState(totalBalance, appCurrency)
{ totalBalance, _ ->
if (isSearchingState) {
FiatAmountState.Empty
} else {
AccountCryptoPortfolioItemStateConverter.createFiatAmountState(totalBalance, appCurrency)
}
}

View file

@ -16,13 +16,9 @@ internal enum class SwapMarketCategory(
val title: TextReference,
val order: TokenMarketListConfig.Order,
) {
Trending(
title = resourceReference(R.string.markets_sort_by_trending_title),
order = TokenMarketListConfig.Order.Trending,
),
ExperiencedBuyers(
title = resourceReference(R.string.markets_sort_by_experienced_buyers_title),
order = TokenMarketListConfig.Order.Buyers,
MarketCap(
title = resourceReference(R.string.markets_sort_by_rating_title),
order = TokenMarketListConfig.Order.ByRating,
),
TopGainers(
title = resourceReference(R.string.markets_sort_by_top_gainers_title),

View file

@ -51,7 +51,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.Trending)
private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.MarketCap)
val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(

View file

@ -1,5 +1,16 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -7,6 +18,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
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.*
import androidx.compose.ui.Alignment
@ -15,16 +27,21 @@ 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.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.lerp
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.dp
import com.tangem.common.ui.tokens.portfolioTokensList
import com.tangem.common.ui.tokens.NonContentItemContent
import com.tangem.common.ui.tokens.SlideInItemVisibility
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.account.toBoxSize
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.SearchBar
@ -34,6 +51,8 @@ import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.token.AccountItemPreviewData
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.NON_CONTENT_TOKENS_LIST_KEY
import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
import com.tangem.core.ui.components.tokenlist.TokenListItem
import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
@ -41,26 +60,42 @@ 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.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.header.TangemHeaderRow
import com.tangem.core.ui.ds.row.internal.TangemRowTailUM
import com.tangem.core.ui.ds.row.token.TangemTokenRow
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.ds.row.token.internal.TokenRowTitle
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.ProvideSharedTransitionScope
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection
import com.tangem.core.ui.utils.sharedBoundsSafely
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.utils.StringsSigns.DOT
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlin.random.Random
private const val LOAD_MORE_BUFFER = 25
private const val ACCOUNT_COLLAPSE_STEP_MS = 50
private const val ACCOUNT_COLLAPSE_MAX_DELAY_MS = 250
private const val ACCOUNT_COLLAPSE_BASE_DELAY_MS = 150
private const val ACCOUNT_CONTENT_ANIM_MS = 350
private const val ACCOUNT_CONTENT_ANIM_DELAY_MS = 90
private const val ACCOUNT_BOUNDS_ANIM_MS = 250
private val ChooseTokenFullUM.isNotFoundState: Boolean
get() {
@ -298,11 +333,10 @@ private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBal
when (tokensListData) {
is TokenListUMData.AccountList -> {
tokensListData.tokensList.forEachIndexed { index, item ->
portfolioTokensList(
accountWithTokens(
portfolio = item,
portfolioIndex = index,
isBalanceHidden = isBalanceHidden,
testTag = BuyTokenScreenTestTags.LAZY_LIST_ITEM,
)
}
}
@ -338,6 +372,298 @@ private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isB
)
}
@Suppress("LongMethod")
private fun LazyListScope.accountWithTokens(
portfolio: TokensListItemUM.Portfolio,
portfolioIndex: Int,
isBalanceHidden: Boolean,
) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
val lastIndex = maxOf(tokens.lastIndex.inc(), 1)
item(key = "account-${portfolio.id}", contentType = "choose-token-account") {
val effectiveLastIndex by animateIntAsState(
targetValue = if (isExpanded) lastIndex else 0,
animationSpec = if (isExpanded) {
snap()
} else {
snap(
delayMillis = minOf(
ACCOUNT_COLLAPSE_STEP_MS * maxOf(tokens.lastIndex, 0),
ACCOUNT_COLLAPSE_MAX_DELAY_MS,
) + ACCOUNT_COLLAPSE_BASE_DELAY_MS,
)
},
label = "accountLastIndex",
)
AccountRow(
portfolio = portfolio,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = portfolioIndex }
.roundedShapeItemDecoration(
currentIndex = 0,
radius = TangemTheme.dimens.radius14,
lastIndex = effectiveLastIndex,
backgroundColor = TangemTheme.colors.background.primary,
),
)
}
if (portfolio.content is PortfolioItemContentUM.Empty) {
item(key = "$NON_CONTENT_TOKENS_LIST_KEY account-${portfolio.id}") {
SlideInItemVisibility(
visible = isExpanded,
currentIndex = 1,
lastIndex = lastIndex,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = 1,
radius = TangemTheme.dimens.radius14,
lastIndex = 1,
backgroundColor = TangemTheme.colors.background.primary,
),
) {
NonContentItemContent(modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing16))
}
}
return
}
itemsIndexed(
items = tokens,
key = { _, token -> "${token.id}-choose-account-${portfolio.id}" },
contentType = { _, token -> token::class.java },
) { tokenIndex, token ->
val indexWithHeader = tokenIndex.inc()
val lastTokenBottomPadding = TangemTheme.dimens.spacing8
SlideInItemVisibility(
visible = isExpanded,
currentIndex = tokenIndex,
lastIndex = lastIndex,
modifier = Modifier
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.roundedShapeItemDecoration(
currentIndex = indexWithHeader,
radius = TangemTheme.dimens.radius14,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
),
) {
PortfolioTokensListItem(
state = token,
isBalanceHidden = isBalanceHidden,
modifier = Modifier.conditional(indexWithHeader == lastIndex) {
padding(bottom = lastTokenBottomPadding)
},
)
}
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun AccountRow(
portfolio: TokensListItemUM.Portfolio,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
val tokenRowUM = accountTokenRowUM(portfolio)
val subtitle = (tokenRowUM.subtitleUM as? TangemTokenRowUM.SubtitleUM.Content)?.text
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
ProvideSharedTransitionScope(Modifier.weight(1f)) {
val iconSharedContentState = rememberSharedContentState(key = "account-icon-${portfolio.id}")
val titleSharedContentState = rememberSharedContentState(key = "account-title-${portfolio.id}")
val boundsTransform = BoundsTransform { _, _ -> tween(ACCOUNT_BOUNDS_ANIM_MS) }
AnimatedContent(
targetState = portfolio.isExpanded,
transitionSpec = {
fadeIn(animationSpec = tween(ACCOUNT_CONTENT_ANIM_MS, delayMillis = ACCOUNT_CONTENT_ANIM_DELAY_MS))
.togetherWith(fadeOut(animationSpec = tween(ACCOUNT_CONTENT_ANIM_MS)))
},
label = "accountExpand",
) { isExpandedState ->
val animatedContentScope = this
val composables = remember(tokenRowUM) {
AccountRowComposables(
icon = { iconModifier ->
val iconSize = if (isExpandedState) {
AccountIconSize.RedesignExtraSmall
} else {
AccountIconSize.RedesignedDefault
}
val sizedIcon = when (val icon = tokenRowUM.headIconUM) {
is TangemIconUM.Currency -> icon.copy(
currencyIconState = when (val iconState = icon.currencyIconState) {
is CurrencyIconState.CryptoPortfolio.Icon -> iconState.copy(size = iconSize)
is CurrencyIconState.CryptoPortfolio.Letter -> iconState.copy(size = iconSize)
else -> iconState
},
)
else -> icon
}
TangemIcon(
tangemIconUM = sizedIcon,
modifier = iconModifier
.size(iconSize.toBoxSize())
.sharedBoundsSafely(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
),
)
},
title = { titleModifier ->
val targetFraction = if (isExpandedState) 0f else 1f
val animationFraction = animateFloatAsState(
targetValue = targetFraction,
animationSpec = tween(durationMillis = ACCOUNT_CONTENT_ANIM_MS),
label = "accountTitle",
)
val startStyle = TangemTheme.typography2.captionSemibold12
val stopStyle = TangemTheme.typography2.bodySemibold16
val textStyle by remember(animationFraction.value) {
derivedStateOf { lerp(startStyle, stopStyle, animationFraction.value) }
}
val resizedTitle = when (val titleUM = tokenRowUM.titleUM) {
is TangemTokenRowUM.TitleUM.Content -> titleUM.copy(
text = styledStringReference(
titleUM.text.resolveReference(),
{ textStyle.toSpanStyle() },
),
)
else -> titleUM
}
TokenRowTitle(
titleUM = resizedTitle,
modifier = titleModifier.sharedBoundsSafely(
sharedContentState = titleSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
resizeMode = scaleToBounds(ContentScale.Fit, Alignment.CenterStart),
),
)
},
)
}
if (isExpandedState) {
TangemHeaderRow(
subtitle = subtitle,
isBalanceHidden = isBalanceHidden,
titleContent = composables.title,
headContent = composables.icon,
tailUM = TangemRowTailUM.Empty,
onItemClick = tokenRowUM.onItemClick,
)
} else {
TangemTokenRow(
tokenRowUM = tokenRowUM,
isBalanceHidden = isBalanceHidden,
headComponent = composables.icon,
titleComponent = composables.title,
)
}
}
}
AccountTail(
isExpanded = portfolio.isExpanded,
onClick = { tokenRowUM.onItemClick?.invoke() },
)
}
}
@Composable
private fun AccountTail(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.padding(start = TangemTheme.dimens2.x2, end = TangemTheme.dimens2.x4)
.size(TangemTheme.dimens2.x9)
.clip(CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = isExpanded,
contentAlignment = Alignment.Center,
label = "accountTail",
) { expanded ->
if (expanded) {
Icon(
modifier = Modifier
.offset(x = TangemTheme.dimens.spacing2)
.size(TangemTheme.dimens2.x4),
painter = painterResource(id = R.drawable.ic_minimize_24),
tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant,
contentDescription = null,
)
} else {
AccountExpandButton()
}
}
}
}
@Composable
private fun AccountExpandButton(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(TangemTheme.dimens2.x9)
.clip(CircleShape)
.background(TangemTheme.colors2.button.backgroundSecondary),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x5),
painter = painterResource(id = R.drawable.ic_chewron_down_20),
tint = TangemTheme.colors2.button.iconPrimary,
contentDescription = null,
)
}
}
@Stable
private class AccountRowComposables(
val title: @Composable (Modifier) -> Unit,
val icon: @Composable (Modifier) -> Unit,
)
private fun accountTokenRowUM(portfolio: TokensListItemUM.Portfolio): TangemTokenRowUM.Content {
val account = portfolio.tokenItemUM
val name = (account.titleState as? TokenItemState.TitleState.Content)?.text ?: TextReference.EMPTY
val tokensCount = (account.subtitleState as? TokenItemState.SubtitleState.TextContent)?.value
val balance = (account.fiatAmountState as? TokenItemState.FiatAmountState.Content)?.text
return TangemTokenRowUM.Content(
id = portfolio.id,
headIconUM = TangemIconUM.Currency(currencyIconState = account.iconState),
titleUM = TangemTokenRowUM.TitleUM.Content(text = name),
subtitleUM = buildAccountSubtitle(tokensCount, balance)
?.let { TangemTokenRowUM.SubtitleUM.Content(text = it) }
?: TangemTokenRowUM.SubtitleUM.Empty,
topEndContentUM = TangemTokenRowUM.EndContentUM.Empty,
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Empty,
tailUM = TangemRowTailUM.Empty,
onItemClick = { account.onItemClick?.invoke(account) },
onItemLongClick = null,
)
}
private fun buildAccountSubtitle(tokensCount: TextReference?, balance: String?): TextReference? {
return when {
tokensCount != null && balance != null ->
combinedReference(tokensCount, stringReference(" $DOT "), stringReference(balance))
tokensCount != null -> tokensCount
balance != null -> stringReference(balance)
else -> null
}
}
private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) {
item("EmptyTokensList") {
Box(

View file

@ -30,7 +30,6 @@ import com.tangem.common.ui.markets.action.TokenActionsContext
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.UserPortfolioComponent
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -42,15 +41,12 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
chooseTokenComponentFactory: ChooseTokenComponent.Factory,
tokenActionsComponentFactory: TokenActionsComponent.Factory,
userPortfolioComponentFactory: UserPortfolioComponent.Factory,
walletFeatureToggles: WalletFeatureToggles,
) : AppComponentContext by appComponentContext, ManageFundsComponent {
private val model: ManageFundsModel = getOrCreateModel(params)
private val isCompactTokenActions: Boolean = params.launchMode is ManageFundsComponent.LaunchMode.TokenActionsOnly
private val isAddFundsStage1Enabled: Boolean = walletFeatureToggles.isAddFundsStage1Enabled
private val tokenActionsComponent: TokenActionsComponent by lazy {
tokenActionsComponentFactory.create(
context = child(key = "manageFundsTokenActions"),
@ -107,7 +103,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
)
}
WithOptionalRedesignTheme(isEnabled = isAddFundsStage1Enabled) {
TangemThemeRedesign {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
onBack = if (canGoBack) model::onBack else ::dismiss,
config = TangemBottomSheetConfig(
@ -179,7 +175,7 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
) {
userPortfolioComponent.Content(modifier)
}
ManageFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier)
is ManageFundsModel.UiRoute.TokenActions -> tokenActionsComponent.Content(modifier)
}
}
@ -191,8 +187,13 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
onCloseClick: () -> Unit,
) {
val spec = route.uiSpec(model.flowType)
val title = if (route is ManageFundsModel.UiRoute.TokenActions) {
route.title
} else {
spec.title
}
TangemTopBar(
title = spec.title,
title = title,
subtitle = spec.subtitle,
type = TangemTopBarType.BottomSheet,
startContent = if (canGoBack) {
@ -219,15 +220,6 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
)
}
@Composable
private fun WithOptionalRedesignTheme(isEnabled: Boolean, content: @Composable () -> Unit) {
if (isEnabled) {
TangemThemeRedesign(content = content)
} else {
content()
}
}
@AssistedFactory
interface Factory : ManageFundsComponent.Factory {
override fun create(

View file

@ -8,9 +8,9 @@ internal sealed class ManageFundsAnalyticsEvent(
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = CATEGORY, event = event, params = params) {
class MethodScreenOpened(source: String) : ManageFundsAnalyticsEvent(
class MethodScreenOpened(source: AnalyticsParam.ScreensSources) : ManageFundsAnalyticsEvent(
event = "Method Screen Opened",
params = mapOf(AnalyticsParam.SOURCE to source),
params = mapOf(AnalyticsParam.SOURCE to source.value),
)
class ButtonBuy : ManageFundsAnalyticsEvent(event = "Button - Buy")
@ -21,6 +21,5 @@ internal sealed class ManageFundsAnalyticsEvent(
companion object {
private const val CATEGORY = "Add Funds"
const val SOURCE_MAIN_SCREEN = "Main Screen"
}
}

View file

@ -7,9 +7,14 @@ import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.markets.action.CryptoCurrencyData
import com.tangem.common.ui.markets.action.TokenActionsBSContentUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.TransferAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
@ -26,6 +31,7 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPa
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.tokenactions.BottomAction
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.managefunds.analytics.ManageFundsAnalyticsEvent
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.userportfolio.state.UserPortfolioStateController
@ -146,11 +152,20 @@ internal class ManageFundsModel @Inject constructor(
}
override fun onQuickActionClick(action: TokenActionsBSContentUM.Action, shouldDismiss: Boolean) {
val event = when (action) {
TokenActionsBSContentUM.Action.Buy -> ManageFundsAnalyticsEvent.ButtonBuy()
TokenActionsBSContentUM.Action.Exchange -> ManageFundsAnalyticsEvent.ButtonSwap()
TokenActionsBSContentUM.Action.Receive -> ManageFundsAnalyticsEvent.ButtonReceive()
else -> null
val event = when (flowType) {
ManageFundsComponent.FlowType.AddFunds -> when (action) {
TokenActionsBSContentUM.Action.Buy -> ManageFundsAnalyticsEvent.ButtonBuy()
TokenActionsBSContentUM.Action.Exchange -> ManageFundsAnalyticsEvent.ButtonSwap()
TokenActionsBSContentUM.Action.Receive -> ManageFundsAnalyticsEvent.ButtonReceive()
else -> null
}
ManageFundsComponent.FlowType.Transfer -> when (action) {
TokenActionsBSContentUM.Action.Send -> TransferAnalyticsEvent.ButtonSend()
TokenActionsBSContentUM.Action.Exchange -> TransferAnalyticsEvent.ButtonSwap()
TokenActionsBSContentUM.Action.SendWithSwap -> TransferAnalyticsEvent.ButtonSwapAndSend()
TokenActionsBSContentUM.Action.Sell -> TransferAnalyticsEvent.ButtonSell()
else -> null
}
}
event?.let { analyticsEventHandler.send(it) }
if (shouldDismiss) {
@ -174,9 +189,7 @@ internal class ManageFundsModel @Inject constructor(
private fun initChooseToken(mode: ManageFundsComponent.LaunchMode.ChooseToken) {
chooseTokenBridge.selectWalletTab(mode.userWalletId)
analyticsEventHandler.send(
ManageFundsAnalyticsEvent.MethodScreenOpened(source = ManageFundsAnalyticsEvent.SOURCE_MAIN_SCREEN),
)
sendMethodScreenOpenedEvent()
replaceRoot(UiRoute.ChooseToken)
modelScope.launch {
chooseTokenBridge.onCurrencyChosen.receiveAsFlow().collect(::openTokenActionsFromBridge)
@ -205,20 +218,24 @@ internal class ManageFundsModel @Inject constructor(
params.onDismiss()
return@launch
}
sendMethodScreenOpenedEvent()
tokenActionsTrigger.value = TokenActionsRequest(wallet, match.first, match.second)
replaceRoot(UiRoute.TokenActions)
replaceRoot(tokenActionsRoute(match.second))
}
}
private fun initFilteredByRawId(mode: ManageFundsComponent.LaunchMode.FilteredByRawId) {
modelScope.launch {
val entries = collectFilteredEntries(mode.rawCurrencyId)
if (entries.isNotEmpty()) {
sendMethodScreenOpenedEvent()
}
when (entries.size) {
0 -> params.onDismiss()
1 -> {
val entry = entries.first()
tokenActionsTrigger.value = TokenActionsRequest(entry.userWallet, entry.account, entry.status)
replaceRoot(UiRoute.TokenActions)
replaceRoot(tokenActionsRoute(entry.status))
}
else -> {
filteredEntries.value = entries
@ -246,6 +263,19 @@ internal class ManageFundsModel @Inject constructor(
}
}
private fun sendMethodScreenOpenedEvent() {
val source = when (launchMode) {
is ManageFundsComponent.LaunchMode.ChooseToken -> AnalyticsParam.ScreensSources.Main
is ManageFundsComponent.LaunchMode.TokenActionsOnly -> AnalyticsParam.ScreensSources.Token
is ManageFundsComponent.LaunchMode.FilteredByRawId -> AnalyticsParam.ScreensSources.Market
}
val event = when (flowType) {
ManageFundsComponent.FlowType.AddFunds -> ManageFundsAnalyticsEvent.MethodScreenOpened(source = source)
ManageFundsComponent.FlowType.Transfer -> TransferAnalyticsEvent.MethodScreenOpened(source = source)
}
analyticsEventHandler.send(event)
}
private fun openTokenActionsFromBridge(result: ChooseTokenResult) {
val account = result.account as? AccountStatus.CryptoPortfolio ?: return
openTokenActions(
@ -261,7 +291,12 @@ internal class ManageFundsModel @Inject constructor(
private fun openTokenActions(request: TokenActionsRequest, bottomAction: BottomAction) {
tokenActionsTrigger.value = request
currentBottomAction.value = bottomAction
pushRoute(UiRoute.TokenActions)
pushRoute(tokenActionsRoute(request.status))
}
private fun tokenActionsRoute(status: CryptoCurrencyStatus): UiRoute.TokenActions {
val title = resourceReference(R.string.get_token_title, wrappedList(status.currency.name))
return UiRoute.TokenActions(title = title)
}
private fun replaceRoot(route: UiRoute) {
@ -277,7 +312,7 @@ internal class ManageFundsModel @Inject constructor(
data object Loading : UiRoute
data object ChooseToken : UiRoute
data object UserPortfolio : UiRoute
data object TokenActions : UiRoute
data class TokenActions(val title: TextReference) : UiRoute
}
private data class TokenActionsRequest(

View file

@ -33,7 +33,7 @@ internal fun ManageFundsModel.UiRoute.uiSpec(flowType: ManageFundsComponent.Flow
shouldApplyHorizontalPadding = false,
shouldFillHeight = false,
)
ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec(
is ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec(
title = resourceReference(if (isTransfer) R.string.common_transfer else R.string.common_get_token),
subtitle = null,
shouldApplyHorizontalPadding = true,

View file

@ -31,6 +31,7 @@ import com.tangem.features.commonfeatures.impl.R
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.impl.tokenactions.TokenActionsComponent
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.PortfolioBadgeUM
import com.tangem.features.commonfeatures.impl.tokenactions.ui.state.TokenActionsUM
@ -42,6 +43,7 @@ internal class TokenActionsUiBuilder @Inject constructor(
paramsContainer: ParamsContainer,
private val designFeatureToggles: DesignFeatureToggles,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val walletIconUMConverter: WalletIconUMConverter,
) {
private val params = paramsContainer.require<TokenActionsComponent.Params>()
@ -145,38 +147,47 @@ internal class TokenActionsUiBuilder @Inject constructor(
}
private fun createPortfolioBadge(cryptoCurrencyData: CryptoCurrencyData): PortfolioBadgeUM {
return if (cryptoCurrencyData.isAccountMode) {
val icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon)
val name = cryptoCurrencyData
.account
.account
.accountName
.toUM()
.value
PortfolioBadgeUM.Account(
badge = TangemBadgeUM(
text = name,
tangemIconUM = TangemIconUM.Icon(
iconRes = icon.value.getResId(),
tintReference = { icon.color.getUiColor() },
return when {
cryptoCurrencyData.isAccountMode -> {
val icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon)
val name = cryptoCurrencyData
.account
.account
.accountName
.toUM()
.value
PortfolioBadgeUM.Account(
badge = TangemBadgeUM(
text = name,
tangemIconUM = TangemIconUM.Icon(
iconRes = icon.value.getResId(),
tintReference = { icon.color.getUiColor() },
),
size = TangemBadgeSize.X6,
shape = TangemBadgeShape.Rounded,
iconPosition = TangemBadgeIconPosition.Start,
shouldRespectIconTint = true,
),
size = TangemBadgeSize.X6,
shape = TangemBadgeShape.Rounded,
iconPosition = TangemBadgeIconPosition.Start,
shouldRespectIconTint = true,
),
)
} else {
val userWallet = cryptoCurrencyData.userWallet
PortfolioBadgeUM.Wallet(
name = stringReference(userWallet.name),
deviceIcon = walletIconUMConverter.convert(
getWalletIconUseCase(cryptoCurrencyData.userWallet),
),
)
)
}
isSingleWallet() -> PortfolioBadgeUM.None
else -> {
val userWallet = cryptoCurrencyData.userWallet
PortfolioBadgeUM.Wallet(
name = stringReference(userWallet.name),
deviceIcon = walletIconUMConverter.convert(
getWalletIconUseCase(cryptoCurrencyData.userWallet),
),
)
}
}
}
private fun isSingleWallet(): Boolean {
val count = runCatching { getWalletsUseCase.invokeSync().size }.getOrNull() ?: return false
return count <= 1
}
private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? {
return when (status.value) {
is CryptoCurrencyStatus.Loaded,

View file

@ -127,14 +127,16 @@ private fun QuickActionsList(state: TokenActionsUM, modifier: Modifier = Modifie
Modifier.testTag(TokenActionsTestTags.BUY_ACTION)
else -> Modifier
}
val isEnabled = actionUM !in state.quickActions.disabledActions
TokenActionRow(
modifier = actionModifier,
iconRes = actionUM.icon,
title = actionUM.title,
description = actionUM.description,
onClick = { state.quickActions.onQuickActionClick(actionUM) },
isEnabled = isEnabled,
onClick = { state.quickActions.onQuickActionClick(actionUM) }.takeIf { isEnabled },
onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }
.takeIf { actionUM.isLongClickAvailable },
.takeIf { actionUM.isLongClickAvailable && isEnabled },
)
}
}

View file

@ -76,7 +76,7 @@ internal class DefaultMarketsTokenDetailsComponent(
}
private val portfolioBlockComponent: PortfolioBlockComponent? =
if (designFeatureToggles.isRedesignEnabled) {
if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) {
portfolioBlockComponentFactory.create(
context = child("portfolio_block"),
params = PortfolioBlockComponent.Params(token = updatedParams.token),

View file

@ -35,6 +35,8 @@ import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -96,6 +98,7 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi
@Composable
private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = Modifier) {
val hapticManager = LocalHapticManager.current
FloatingCard(modifier = modifier) {
TangemRowContainer(modifier = Modifier.clickableSingle(onClick = state.onRowClick)) {
Text(
@ -128,7 +131,10 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M
iconPosition = TangemButtonIconPosition.Start,
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X9,
onClick = state.onAddFundsClick,
onClick = {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
state.onAddFundsClick()
},
),
)
@ -144,7 +150,10 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M
),
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X9,
onClick = state.onRowClick,
onClick = {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
state.onRowClick()
},
),
)
}
@ -153,6 +162,7 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M
@Composable
private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = Modifier) {
val hapticManager = LocalHapticManager.current
FloatingCard(modifier = modifier) {
Row(
modifier = Modifier
@ -189,7 +199,10 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier =
text = resourceReference(R.string.common_add),
shape = TangemButtonShape.Rounded,
size = TangemButtonSize.X9,
onClick = state.onAddClick,
onClick = {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
state.onAddClick()
},
),
)
}

View file

@ -393,6 +393,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
}
fun openAddFunds(rawCurrencyId: com.tangem.domain.models.currency.CryptoCurrency.RawID) {
analyticsEventHandler.send(analyticsEventBuilder.addFundsClicked())
addFundsSheetNavigation.activate(AddFundsSlotRoute(rawCurrencyId = rawCurrencyId))
}
@ -401,6 +402,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
AppRoute.CurrencyDetails(
userWalletId = result.wallet.walletId,
currency = result.addedCurrency.currency,
shouldShowMarketBlock = false,
),
)
}

View file

@ -70,6 +70,11 @@ internal class MarketDetailsAnalyticsEvent(
event = "Button - Share",
params = mapOf("Token" to token.symbol),
)
fun addFundsClicked() = MarketDetailsAnalyticsEvent(
event = "Button - Add Funds",
params = mapOf("Token" to token.symbol),
)
}
enum class IntervalType(val source: String) {

View file

@ -19,6 +19,8 @@ import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.tabs.TangemSegmentUM
import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.impl.R
@ -117,6 +119,7 @@ private fun OptionsV2(
modifier: Modifier = Modifier,
) {
var isShowDropdownMenu by rememberSaveable { mutableStateOf(false) }
val hapticManager = LocalHapticManager.current
val segmentItems = remember {
persistentListOf(
@ -147,7 +150,10 @@ private fun OptionsV2(
horizontalArrangement = Arrangement.SpaceBetween,
) {
PrimaryInverseTangemButton(
onClick = { isShowDropdownMenu = true },
onClick = {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
isShowDropdownMenu = true
},
iconPosition = RedesignTangemButtonIconPosition.End,
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_chewron_down_20,

View file

@ -126,7 +126,7 @@ internal class ForYouTokenListConverter(
headIconUM = TangemIconUM.Currency(CurrencyIconState.Empty()),
titleUM = TangemTokenRowUM.TitleUM.Content(text = resourceReference(R.string.common_other)),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = pluralReference(R.plurals.common_assets, otherAssets.count()),
text = pluralReference(R.plurals.market_chart_assets_android, otherAssets.count()),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference(

View file

@ -149,7 +149,7 @@ internal class ForYouTokenListConverterTest {
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
assertThat(otherRow.id).isEqualTo("for_you_other_assets")
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_assets, count = 1))
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 1))
}
@Test
@ -172,7 +172,7 @@ internal class ForYouTokenListConverterTest {
// Assert
val otherRow = result.last().tokenRowUM as TangemTokenRowUM.Content
val subtitle = otherRow.subtitleUM as TangemTokenRowUM.SubtitleUM.Content
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.common_assets, count = 3))
assertThat(subtitle.text).isEqualTo(pluralReference(R.plurals.market_chart_assets_android, count = 3))
}
@Test

View file

@ -85,4 +85,9 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.jodatime)
implementation(deps.decompose.ext.compose)
implementation(deps.kotlin.immutable.collections)
/** Tests */
testImplementation(projects.test.core)
testImplementation(projects.common.test)
}

View file

@ -3,6 +3,7 @@ package com.tangem.features.onramp.main.entity
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.TextReference
@Immutable
@ -11,9 +12,12 @@ internal sealed interface OnrampMainComponentUM {
val topBarConfig: OnrampMainTopBarUM
val errorNotification: NotificationUM?
val buyNotSupportedMessage: TangemMessageUM?
data class InitialLoading(
override val topBarConfig: OnrampMainTopBarUM,
override val errorNotification: NotificationUM?,
override val buyNotSupportedMessage: TangemMessageUM? = null,
) : OnrampMainComponentUM
data class Content(
@ -22,6 +26,7 @@ internal sealed interface OnrampMainComponentUM {
val amountBlockState: OnrampAmountBlockUM,
val offersBlockState: OnrampOffersBlockUM,
val onrampAmountButtonUMState: OnrampAmountButtonUMState,
override val buyNotSupportedMessage: TangemMessageUM? = null,
) : OnrampMainComponentUM
}

View file

@ -7,10 +7,12 @@ import androidx.compose.ui.text.input.KeyboardType
import com.tangem.common.ui.amountScreen.models.AmountFieldModel
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.TangemMessageIconPosition
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
@ -21,6 +23,7 @@ import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.*
import com.tangem.utils.Provider
import java.math.BigDecimal
import com.tangem.core.ui.R as CoreUiR
internal class OnrampStateFactory(
private val currentStateProvider: Provider<OnrampMainComponentUM>,
@ -120,6 +123,42 @@ internal class OnrampStateFactory(
}
}
fun getBuyNotSupportedState(state: OnrampMainComponentUM = currentStateProvider()): OnrampMainComponentUM {
val message = buildBuyNotSupportedMessage()
return when (state) {
is OnrampMainComponentUM.Content -> state.copy(
buyNotSupportedMessage = message,
errorNotification = null,
offersBlockState = OnrampOffersBlockUM.Empty,
onrampAmountButtonUMState = OnrampAmountButtonUMState.None,
amountBlockState = state.amountBlockState.copy(
amountFieldModel = state.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Empty,
),
)
is OnrampMainComponentUM.InitialLoading -> state.copy(
buyNotSupportedMessage = message,
errorNotification = null,
)
}
}
private fun buildBuyNotSupportedMessage(): TangemMessageUM = TangemMessageUM(
id = "buy_not_supported",
title = resourceReference(
id = R.string.onramp_token_is_not_supported_banner_title,
formatArgs = wrappedList(cryptoCurrency.name),
),
subtitle = resourceReference(R.string.onramp_token_is_not_supported_banner_subtitle),
messageEffect = TangemMessageEffect.None,
iconUM = TangemIconUM.Icon(
iconRes = CoreUiR.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors2.graphic.status.attention },
),
iconPosition = TangemMessageIconPosition.Leading,
)
private fun getNoPairsErrorState(): OnrampMainComponentUM {
val state = currentStateProvider()
val contentState = state as? OnrampMainComponentUM.Content ?: return state

View file

@ -2,6 +2,7 @@ package com.tangem.features.onramp.main.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -9,12 +10,15 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.main.OnrampMainComponent
import com.tangem.features.onramp.main.entity.*
@ -23,6 +27,7 @@ import com.tangem.features.onramp.main.entity.factory.OnrampAmountStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampOffersStateFactory
import com.tangem.features.onramp.main.entity.factory.OnrampStateFactory
import com.tangem.features.onramp.utils.sendOnrampErrorEvent
import com.tangem.features.onramp.utils.sendProviderCalculatedEvent
import com.tangem.features.onramp.utils.showDemoModeWarningIfNeeded
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -30,9 +35,9 @@ import com.tangem.utils.coroutines.PeriodicTask
import com.tangem.utils.coroutines.SingleTaskScheduler
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass")
@ -46,6 +51,7 @@ internal class OnrampMainComponentModel @Inject constructor(
private val fetchQuotesUseCase: OnrampFetchQuotesUseCase,
private val getOnrampQuotesUseCase: GetOnrampQuotesUseCase,
private val fetchPairsUseCase: OnrampFetchPairsUseCase,
private val rampStateManager: RampStateManager,
private val amountInputManager: InputManager,
private val getOnrampOffersUseCase: GetOnrampOffersUseCase,
private val isDemoCardUseCase: IsDemoCardUseCase,
@ -222,6 +228,8 @@ internal class OnrampMainComponentModel @Inject constructor(
}
private fun handleOnrampAvailability(availability: OnrampAvailability) {
// "Buy not supported" notification has priority over the residency flow.
if (state.value.buyNotSupportedMessage != null) return
when (availability) {
is OnrampAvailability.Available -> Unit
is OnrampAvailability.ConfirmResidency,
@ -274,23 +282,30 @@ internal class OnrampMainComponentModel @Inject constructor(
ifLeft = ::handleOnrampError,
ifRight = { country ->
if (country == null) return@onEach
state.update { prevState ->
when (prevState) {
is OnrampMainComponentUM.Content -> {
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampMainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount)
}
}
// Resolve token-level buy support BEFORE emitting any Content state, so an
// unsupported token never briefly shows an enabled amount field — otherwise it
// would grab focus and flash the keyboard before being disabled.
if (isTokenNotSupportedForBuy()) {
showBuyNotSupported(country)
} else {
state.update { prevState -> getCountryUpdatedState(prevState, country) }
updatePairsAndQuotes()
}
updatePairsAndQuotes()
},
)
}
.launchIn(modelScope)
}
private fun getCountryUpdatedState(
prevState: OnrampMainComponentUM,
country: OnrampCountry,
): OnrampMainComponentUM = when (prevState) {
is OnrampMainComponentUM.Content -> amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
is OnrampMainComponentUM.InitialLoading ->
stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount)
}
private fun subscribeToQuotesUpdate() {
getOnrampQuotesUseCase.invoke()
.conflate()
@ -316,6 +331,10 @@ internal class OnrampMainComponentModel @Inject constructor(
state.update { stateFactory.getErrorState(onRefresh = ::onRetryQuotes) }
}
else -> {
analyticsEventHandler.sendProviderCalculatedEvent(
quotes = quotes,
tokenSymbol = params.cryptoCurrency.symbol,
)
state.update { prevState ->
val resetState = amountStateFactory.getAmountSecondaryFieldResetState()
if (prevState is OnrampMainComponentUM.Content &&
@ -356,11 +375,43 @@ internal class OnrampMainComponentModel @Inject constructor(
)
}
private suspend fun isTokenNotSupportedForBuy(): Boolean {
// Token-level "cannot be bought", independent of country: the asset is either flagged as
// not onrampable (BuyUnavailable) or absent from the express asset list (AssetNotFound).
// Transient express states (loading/unreachable) are NOT treated as "not supported".
val reason = rampStateManager.availableForBuy(
userWallet = userWallet,
cryptoCurrency = params.cryptoCurrency,
)
return reason is ScenarioUnavailabilityReason.BuyUnavailable ||
reason is ScenarioUnavailabilityReason.AssetNotFound
}
private fun handleOnrampError(onrampError: OnrampError) {
TangemLogger.e(onrampError.toString())
state.update { stateFactory.getOnrampErrorState(onrampError) }
}
private fun showBuyNotSupported(country: OnrampCountry) {
if (state.value.buyNotSupportedMessage != null) return
analyticsEventHandler.send(
OnrampAnalyticsEvent.NoticeBuyNotSupported(
source = params.source,
tokenSymbol = params.cryptoCurrency.symbol,
blockchain = params.cryptoCurrency.network.name,
),
)
quotesTaskScheduler.cancelTask()
// "Not supported" has priority: hide the residency bottom sheet if it was already shown.
bottomSheetNavigation.dismiss()
// Emit the not-supported state in a single update built from the ready state, so the amount
// field never appears enabled first (no focus/keyboard flash).
state.update { prevState ->
stateFactory.getBuyNotSupportedState(getCountryUpdatedState(prevState, country))
}
}
private fun sendOnrampQuotesErrorAnalytic(quotes: List<OnrampQuote>) {
quotes.forEach { errorState ->
when (errorState) {

View file

@ -125,7 +125,9 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin
)
LaunchedEffect(key1 = Unit) {
requester.requestFocus()
if (!amountField.isError) {
requester.requestFocus()
}
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
@ -75,7 +76,7 @@ private fun InitialLoading(state: OnrampMainComponentUM.InitialLoading, modifier
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
OnrampAmountContentLoading()
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
OnrampNotifications(state = state)
}
}
@ -134,6 +135,16 @@ private fun Content(state: OnrampMainComponentUM.Content, modifier: Modifier = M
OnrampOffersContent(state = state.offersBlockState)
if (state.errorNotification != null) Notification(config = state.errorNotification.config)
OnrampNotifications(state = state)
}
}
@Composable
private fun OnrampNotifications(state: OnrampMainComponentUM, modifier: Modifier = Modifier) {
val buyNotSupportedMessage = state.buyNotSupportedMessage
val errorNotification = state.errorNotification
when {
buyNotSupportedMessage != null -> TangemMessage(messageUM = buyNotSupportedMessage, modifier = modifier)
errorNotification != null -> Notification(config = errorNotification.config, modifier = modifier)
}
}

View file

@ -23,7 +23,6 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.tokenlist.entity.AccountAvailabilityUM
@ -270,9 +269,7 @@ internal class OnrampTokenListModel @Inject constructor(
val isNotUnreachable = status.value !is CryptoCurrencyStatus.Unreachable
val isAvailable = when (params.filterOperation) {
OnrampOperation.BUY -> {
isAvailableForBuy
} // unreachable state is available for Buy operation
OnrampOperation.BUY -> true
OnrampOperation.SELL -> isNotUnreachable
OnrampOperation.SWAP -> {
isNotUnreachable && isAvailableForBuy
@ -295,12 +292,7 @@ internal class OnrampTokenListModel @Inject constructor(
private suspend fun checkAvailabilityByOperation(status: CryptoCurrencyStatus): Boolean {
return when (params.filterOperation) {
OnrampOperation.BUY -> {
rampStateManager.availableForBuy(
userWallet = userWallet,
cryptoCurrency = status.currency,
).isAvailable()
}
OnrampOperation.BUY -> true
OnrampOperation.SELL -> {
rampStateManager.availableForSell(
userWalletId = userWallet.walletId,
@ -318,8 +310,4 @@ internal class OnrampTokenListModel @Inject constructor(
}
}
}
private fun ScenarioUnavailabilityReason.isAvailable(): Boolean {
return this == ScenarioUnavailabilityReason.None
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.onramp.utils
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampQuote
internal fun AnalyticsEventHandler.sendProviderCalculatedEvent(quotes: List<OnrampQuote>, tokenSymbol: String) {
val quote = quotes.findBestRateQuote() ?: return
send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = quote.provider.info.name,
tokenSymbol = tokenSymbol,
paymentMethod = quote.paymentMethod.name,
),
)
}
private fun List<OnrampQuote>.findBestRateQuote(): OnrampQuote.Data? {
return filterIsInstance<OnrampQuote.Data>().maxByOrNull { it.toAmount.value }
}

View file

@ -0,0 +1,95 @@
package com.tangem.features.onramp.main.entity.factory
import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.features.onramp.impl.R
import com.tangem.features.onramp.main.entity.OnrampAmountButtonUMState
import com.tangem.features.onramp.main.entity.OnrampMainComponentUM
import com.tangem.features.onramp.main.entity.OnrampOffersBlockUM
import com.tangem.features.onramp.main.entity.OnrampSecondaryFieldErrorUM
import com.tangem.utils.Provider
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
internal class OnrampStateFactoryTest {
private lateinit var currentState: OnrampMainComponentUM
private val cryptoCurrency = MockCryptoCurrencyFactory().ethereum
private val factory = OnrampStateFactory(
currentStateProvider = Provider { currentState },
onrampAmountButtonUMStateFactory = OnrampAmountButtonUMStateFactory(),
cryptoCurrency = cryptoCurrency,
onrampIntents = mockk(relaxed = true),
)
@Test
fun `GIVEN initial loading with error WHEN getBuyNotSupportedState THEN shows None message and clears error`() {
// Arrange
currentState = factory.getInitialState(currency = "BTC", onClose = {}, openSettings = {})
.copy(errorNotification = mockk())
// Act
val result = factory.getBuyNotSupportedState()
// Assert
val message = result.buyNotSupportedMessage
assertThat(message).isNotNull()
assertThat(message!!.messageEffect).isEqualTo(TangemMessageEffect.None)
assertThat(message.title).isEqualTo(
resourceReference(
id = R.string.onramp_token_is_not_supported_banner_title,
formatArgs = wrappedList(cryptoCurrency.name),
),
)
assertThat(message.subtitle).isEqualTo(
resourceReference(R.string.onramp_token_is_not_supported_banner_subtitle),
)
assertThat(result.errorNotification).isNull()
}
@Test
fun `GIVEN content with errors WHEN getBuyNotSupportedState THEN message has priority and other errors hidden`() {
// Arrange
currentState = factory.getInitialState(currency = "BTC", onClose = {}, openSettings = {})
val content = factory.getReadyState(currency = USD_CURRENCY) as OnrampMainComponentUM.Content
currentState = content.copy(
errorNotification = mockk(),
offersBlockState = OnrampOffersBlockUM.Loading,
onrampAmountButtonUMState = OnrampAmountButtonUMState.Loaded(persistentListOf()),
amountBlockState = content.amountBlockState.copy(
amountFieldModel = content.amountBlockState.amountFieldModel.copy(isError = true),
secondaryFieldModel = OnrampSecondaryFieldErrorUM.Error(stringReference("error")),
),
)
// Act
val result = factory.getBuyNotSupportedState() as OnrampMainComponentUM.Content
// Assert
assertThat(result.buyNotSupportedMessage).isNotNull()
assertThat(result.errorNotification).isNull()
assertThat(result.offersBlockState).isEqualTo(OnrampOffersBlockUM.Empty)
assertThat(result.onrampAmountButtonUMState).isEqualTo(OnrampAmountButtonUMState.None)
assertThat(result.amountBlockState.secondaryFieldModel).isEqualTo(OnrampSecondaryFieldErrorUM.Empty)
// Amount input is locked (disabled via isError) — like the unsupported-country case.
assertThat(result.amountBlockState.amountFieldModel.isError).isTrue()
}
private companion object {
val USD_CURRENCY = OnrampCurrency(
name = "US Dollar",
code = "USD",
image = null,
precision = 2,
unit = "$",
)
}
}

View file

@ -0,0 +1,123 @@
package com.tangem.features.onramp.utils
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.onramp.analytics.OnrampAnalyticsEvent
import com.tangem.domain.onramp.model.OnrampQuote
import com.tangem.test.core.ProvideTestModels
import io.mockk.Called
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class OnrampProviderCalculatedAnalyticsSenderTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
@BeforeEach
fun resetMocks() {
clearMocks(analyticsEventHandler)
}
@ParameterizedTest
@ProvideTestModels
fun `GIVEN quotes WHEN send THEN provider calculated sent for best-rate provider`(model: SelectionModel) {
// Act
analyticsEventHandler.sendProviderCalculatedEvent(quotes = model.quotes, tokenSymbol = TOKEN_SYMBOL)
// Assert
verify(exactly = 1) {
analyticsEventHandler.send(
OnrampAnalyticsEvent.ProviderCalculated(
providerName = model.expectedProviderName,
tokenSymbol = TOKEN_SYMBOL,
paymentMethod = PAYMENT_METHOD,
),
)
}
}
@Test
fun `GIVEN no quotes WHEN send THEN no event sent`() {
// Act
analyticsEventHandler.sendProviderCalculatedEvent(quotes = emptyList(), tokenSymbol = TOKEN_SYMBOL)
// Assert
verify { analyticsEventHandler wasNot Called }
}
@Test
fun `GIVEN only non-loaded quotes WHEN send THEN no event sent`() {
// Arrange
val quotes = listOf(mockk<OnrampQuote.Error>(), mockk<OnrampQuote.AmountError>())
// Act
analyticsEventHandler.sendProviderCalculatedEvent(quotes = quotes, tokenSymbol = TOKEN_SYMBOL)
// Assert
verify { analyticsEventHandler wasNot Called }
}
private fun provideTestModels() = listOf(
SelectionModel(
name = "highest-rate quote among several is selected",
quotes = listOf(
createQuote(providerName = "Low", rate = BigDecimal("100")),
createQuote(providerName = "High", rate = BigDecimal("120")),
createQuote(providerName = "Mid", rate = BigDecimal("90")),
),
expectedProviderName = "High",
),
SelectionModel(
name = "SEPA quote with lower rate is NOT prioritized, higher-rate quote wins",
quotes = listOf(
createQuote(providerName = "SepaLowerRate", rate = BigDecimal("100")),
createQuote(providerName = "CardHigherRate", rate = BigDecimal("105")),
),
expectedProviderName = "CardHigherRate",
),
SelectionModel(
name = "single loaded quote is selected",
quotes = listOf(
createQuote(providerName = "Single", rate = BigDecimal("100")),
),
expectedProviderName = "Single",
),
SelectionModel(
name = "best-rate loaded quote is selected even when error quotes are present",
quotes = listOf(
mockk<OnrampQuote.Error>(),
createQuote(providerName = "Loaded", rate = BigDecimal("100")),
mockk<OnrampQuote.AmountError>(),
),
expectedProviderName = "Loaded",
),
)
private fun createQuote(providerName: String, rate: BigDecimal): OnrampQuote.Data {
return mockk<OnrampQuote.Data> {
every { provider.info.name } returns providerName
every { paymentMethod.name } returns PAYMENT_METHOD
every { toAmount.value } returns rate
}
}
internal data class SelectionModel(
val name: String,
val quotes: List<OnrampQuote>,
val expectedProviderName: String,
) {
override fun toString(): String = name
}
private companion object {
const val TOKEN_SYMBOL = "BTC"
const val PAYMENT_METHOD = "Card"
}
}

View file

@ -57,9 +57,13 @@ import com.tangem.domain.staking.model.stakekit.action.StakingAction
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.tokens.*
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetAllowanceUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.approval.api.GiveApprovalComponent
@ -72,14 +76,11 @@ import com.tangem.features.staking.impl.presentation.state.*
import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType
import com.tangem.features.staking.impl.presentation.state.events.StakingAlertUM
import com.tangem.features.staking.impl.presentation.state.events.StakingEventFactory
import com.tangem.features.staking.impl.presentation.state.helpers.GetEffectiveStakingFee
import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater
import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader
import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory
import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender
import com.tangem.features.staking.impl.presentation.state.helpers.*
import com.tangem.features.staking.impl.presentation.state.transformers.*
import com.tangem.features.staking.impl.presentation.state.transformers.amount.*
import com.tangem.features.staking.impl.presentation.state.transformers.approval.*
import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalInProgressTransformer
import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetConfirmationStateAssentApprovalTransformer
import com.tangem.features.staking.impl.presentation.state.transformers.confirmation.SetUpdatedAllowanceTransformer
import com.tangem.features.staking.impl.presentation.state.transformers.notifications.AddStakingNotificationsTransformer
import com.tangem.features.staking.impl.presentation.state.transformers.notifications.DismissStakingNotificationsStateTransformer
@ -151,6 +152,7 @@ internal class StakingModel @Inject constructor(
private val coroutineScope: AppCoroutineScope,
private val innerRouter: InnerStakingRouter,
private val messageSender: UiMessageSender,
private val stakingFeatureToggles: StakingFeatureToggles,
appRouter: AppRouter,
) : Model(), StakingClickIntents {
@ -374,6 +376,8 @@ internal class StakingModel @Inject constructor(
minimumTransactionAmount = minimumTransactionAmount,
actionType = uiState.value.actionType,
integration = integration,
isSolanaUnstakeValidationEnabled = stakingFeatureToggles
.isSolanaUnstakeValidationEnabled(),
)
addAll(
@ -636,6 +640,7 @@ internal class StakingModel @Inject constructor(
minimumTransactionAmount = minimumTransactionAmount,
value = value,
integration = integration,
isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(),
),
)
checkSumLimitExceeded()
@ -677,6 +682,7 @@ internal class StakingModel @Inject constructor(
minimumTransactionAmount = minimumTransactionAmount,
actionType = uiState.value.actionType,
integration = integration,
isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(),
),
)
checkSumLimitExceeded()
@ -1359,6 +1365,7 @@ internal class StakingModel @Inject constructor(
value = amountValue,
minimumTransactionAmount = minimumTransactionAmount,
integration = integration,
isSolanaUnstakeValidationEnabled = stakingFeatureToggles.isSolanaUnstakeValidationEnabled(),
),
)
}

View file

@ -14,6 +14,7 @@ internal class AmountChangeStateTransformer(
private val minimumTransactionAmount: EnterAmountBoundary?,
private val value: String,
private val integration: StakingIntegration,
private val isSolanaUnstakeValidationEnabled: Boolean,
) : Transformer<StakingUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
@ -43,6 +44,7 @@ internal class AmountChangeStateTransformer(
maxAmount = maxEnterAmount,
integration = integration,
actionType = prevState.actionType,
isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled,
).transform(updatedAmountState),
)
}

View file

@ -14,6 +14,7 @@ internal class AmountMaxValueStateTransformer(
private val minimumTransactionAmount: EnterAmountBoundary?,
private val actionType: StakingActionCommonType,
private val integration: StakingIntegration,
private val isSolanaUnstakeValidationEnabled: Boolean,
) : Transformer<StakingUiState> {
private val maxEnterAmountConverter = MaxEnterAmountConverter()
@ -40,6 +41,7 @@ internal class AmountMaxValueStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus,
integration = integration,
actionType = prevState.actionType,
isSolanaUnstakeValidationEnabled = isSolanaUnstakeValidationEnabled,
).transform(updatedAmountState),
)
}

View file

@ -16,6 +16,7 @@ import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.common.StakingAmountRequirement
import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType
import com.tangem.features.staking.impl.R
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.lib.crypto.BlockchainUtils.isTron
import com.tangem.utils.extensions.isPositive
import com.tangem.utils.isNullOrZero
@ -28,6 +29,7 @@ internal class AmountRequirementStateTransformer(
private val maxAmount: EnterAmountBoundary,
private val integration: StakingIntegration,
private val actionType: StakingActionCommonType,
private val isSolanaUnstakeValidationEnabled: Boolean = false,
) : Transformer<AmountState> {
override fun transform(prevState: AmountState): AmountState {
return if (prevState is AmountState.Data) {
@ -103,11 +105,17 @@ internal class AmountRequirementStateTransformer(
)
}
is StakingActionCommonType.Exit -> {
integration.exitArgs?.amountRequirement?.getError(
amount = amountDecimal,
minErrorRes = R.string.staking_unstake_amount_requirement_error,
maxErrorRes = R.string.staking_max_amount_requirement_error,
)
if (isSolanaUnstakeValidationEnabled &&
isSolana(cryptoCurrencyStatus.currency.network.rawId)
) {
getSolanaUnstakeError(amount = amountDecimal, staked = maxAmount.amount)
} else {
integration.exitArgs?.amountRequirement?.getError(
amount = amountDecimal,
minErrorRes = R.string.staking_unstake_amount_requirement_error,
maxErrorRes = R.string.staking_max_amount_requirement_error,
)
}
}
else -> null
}
@ -124,6 +132,29 @@ internal class AmountRequirementStateTransformer(
return isEnterOrExit && isTron && !isIntegerOnly
}
private fun getSolanaUnstakeError(amount: BigDecimal, staked: BigDecimal?): TextReference? {
val minimum = integration.exitMinimumAmount?.takeIf { it.isPositive() }
?: integration.enterMinimumAmount
if (minimum == null || staked == null) return null
// Full unstake is always allowed regardless of minimum delegation.
if (amount.compareTo(staked) == 0) return null
if (amount < minimum) {
val formatted = minimum.format { crypto(cryptoCurrencyStatus.currency) }
return resourceReference(
R.string.staking_unstake_amount_requirement_error,
wrappedList(formatted),
)
}
if (staked - amount < minimum) {
return resourceReference(R.string.staking_notification_low_staked_balance_text)
}
return null
}
private fun StakingAmountRequirement.getError(
amount: BigDecimal,
@StringRes minErrorRes: Int,

View file

@ -29,6 +29,7 @@ import com.tangem.domain.staking.*
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.tokens.*
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -58,11 +59,12 @@ internal abstract class StakingModelTestBase {
protected val testUserWalletId = UserWalletId("1234567890ABCDEF")
protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true)
protected open val testIntegrationId: StakingIntegrationID = StakingIntegrationID.StakeKit.Coin.Solana
private val testParams get() = StakingComponent.Params(
userWalletId = testUserWalletId,
cryptoCurrency = testCryptoCurrency,
integrationId = testIntegrationId,
)
private val testParams
get() = StakingComponent.Params(
userWalletId = testUserWalletId,
cryptoCurrency = testCryptoCurrency,
integrationId = testIntegrationId,
)
protected val testYield: Yield = mockk(relaxed = true)
protected val testUserWallet: UserWallet = mockk(relaxed = true)
protected val initialUiState: StakingUiState = mockk(relaxed = true) {
@ -112,6 +114,9 @@ internal abstract class StakingModelTestBase {
private val coroutineScope: AppCoroutineScope = mockk()
protected val innerRouter: InnerStakingRouter = mockk()
protected val messageSender: UiMessageSender = mockk()
protected val stakingFeatureToggles: StakingFeatureToggles = mockk {
every { isSolanaUnstakeValidationEnabled() } returns false
}
@BeforeEach
fun setUp() {
@ -203,6 +208,7 @@ internal abstract class StakingModelTestBase {
coroutineScope = coroutineScope,
innerRouter = innerRouter,
messageSender = messageSender,
stakingFeatureToggles = stakingFeatureToggles,
appRouter = appRouter,
)
}

View file

@ -70,6 +70,30 @@ internal class AmountRequirementStateTransformerTest {
)
}
private fun solanaCryptoStatus(): CryptoCurrencyStatus = mockk(relaxed = true) {
every { currency.network.rawId } returns "solana"
}
private fun solanaExitIntegration(exitMin: BigDecimal?, enterMin: BigDecimal? = null): StakingIntegration =
mockk {
every { exitMinimumAmount } returns exitMin
every { enterMinimumAmount } returns enterMin
every { exitArgs } returns null
}
private fun solanaTransformer(
staked: BigDecimal,
exitMin: BigDecimal?,
enterMin: BigDecimal? = null,
enabled: Boolean = true,
) = AmountRequirementStateTransformer(
cryptoCurrencyStatus = solanaCryptoStatus(),
maxAmount = EnterAmountBoundary(amount = staked, fiatAmount = null, fiatRate = null),
integration = solanaExitIntegration(exitMin = exitMin, enterMin = enterMin),
actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false),
isSolanaUnstakeValidationEnabled = enabled,
)
@Test
fun `WHEN amount exceeds positive maximum THEN max amount error string is used`() {
val transformer = AmountRequirementStateTransformer(
@ -149,4 +173,143 @@ internal class AmountRequirementStateTransformerTest {
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_max_amount_requirement_error)
}
@Test
fun `WHEN Solana full unstake THEN no error`() {
val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1"))
val result = transformer.transform(amountState(BigDecimal("5"))) as AmountState.Data
assertThat(result.amountTextField.isError).isFalse()
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
@Test
fun `WHEN Solana full unstake of small stake below minimum THEN no error`() {
val small = BigDecimal("0.098090754")
val transformer = solanaTransformer(staked = small, exitMin = BigDecimal("1"))
val result = transformer.transform(amountState(small)) as AmountState.Data
assertThat(result.amountTextField.isError).isFalse()
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
@Test
fun `WHEN Solana partial unstake below minimum THEN unstake min error and button disabled`() {
val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1"))
val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat(result.isPrimaryButtonEnabled).isFalse()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_unstake_amount_requirement_error)
}
@Test
fun `WHEN Solana partial unstake leaving remainder below minimum THEN low staked balance error and button disabled`() {
val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1"))
val result = transformer.transform(amountState(BigDecimal("4.5"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat(result.isPrimaryButtonEnabled).isFalse()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_notification_low_staked_balance_text)
}
@Test
fun `WHEN Solana partial unstake violating both minimums THEN unstake min error takes priority`() {
val transformer = solanaTransformer(staked = BigDecimal("1.5"), exitMin = BigDecimal("1"))
val result = transformer.transform(amountState(BigDecimal("0.7"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_unstake_amount_requirement_error)
}
@Test
fun `WHEN Solana partial unstake with both parts above minimum THEN no error`() {
val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1"))
val result = transformer.transform(amountState(BigDecimal("1.5"))) as AmountState.Data
assertThat(result.amountTextField.isError).isFalse()
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
@Test
fun `WHEN Solana amount exactly at minimum THEN no error`() {
val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1"))
val result = transformer.transform(amountState(BigDecimal("1"))) as AmountState.Data
assertThat(result.amountTextField.isError).isFalse()
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
@Test
fun `WHEN Solana remainder exactly at minimum THEN no error`() {
val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = BigDecimal("1"))
val result = transformer.transform(amountState(BigDecimal("4"))) as AmountState.Data
assertThat(result.amountTextField.isError).isFalse()
}
@Test
fun `WHEN Solana exit minimum is zero THEN falls back to enter minimum`() {
val transformer = solanaTransformer(
staked = BigDecimal("5"),
exitMin = BigDecimal.ZERO,
enterMin = BigDecimal("1"),
)
val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data
assertThat(result.amountTextField.isError).isTrue()
assertThat((result.amountTextField.error as TextReference.Res).id)
.isEqualTo(R.string.staking_unstake_amount_requirement_error)
}
@Test
fun `WHEN Solana both minimums null THEN partial unstake allowed`() {
val transformer = solanaTransformer(staked = BigDecimal("5"), exitMin = null, enterMin = null)
val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data
assertThat(result.amountTextField.isError).isFalse()
assertThat(result.isPrimaryButtonEnabled).isTrue()
}
@Test
fun `WHEN Solana validation disabled THEN partial unstake below minimum allowed`() {
val transformer = solanaTransformer(
staked = BigDecimal("5"),
exitMin = BigDecimal("1"),
enabled = false,
)
val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data
assertThat(result.amountTextField.isError).isFalse()
}
@Test
fun `WHEN validation enabled but currency is not Solana THEN Solana rule does not apply`() {
val transformer = AmountRequirementStateTransformer(
cryptoCurrencyStatus = cryptoCurrencyStatus, // relaxed mock: network.rawId is not "solana"
maxAmount = EnterAmountBoundary(amount = BigDecimal("5"), fiatAmount = null, fiatRate = null),
integration = exitIntegrationWith(minimum = null, maximum = null),
actionType = StakingActionCommonType.Exit(partiallyUnstakeDisabled = false),
isSolanaUnstakeValidationEnabled = true,
)
val result = transformer.transform(amountState(BigDecimal("0.5"))) as AmountState.Data
// Non-Solana: falls through to legacy exitArgs path (minimum null → no error), NOT the Solana remainder rule.
assertThat(result.amountTextField.isError).isFalse()
}
}

View file

@ -297,7 +297,6 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
expressOperationType = ExpressOperationType.SWAP,
)
} else {
@ -306,7 +305,6 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
expressOperationType = ExpressOperationType.SWAP,
)
}
@ -317,7 +315,6 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
}
}
@ -332,7 +329,6 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
provider: SwapProvider,
amount: SwapAmount,
reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState>? {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
@ -364,7 +360,6 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
}
@ -460,7 +455,6 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
provider: SwapProvider,
amount: SwapAmount,
reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState> {
val maybeQuotes = repository.findBestQuote(
@ -482,7 +476,6 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
provider = provider,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
)
}
@ -520,43 +513,27 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
provider: SwapProvider,
amount: SwapAmount,
reduceBalanceBy: BigDecimal,
): Pair<SwapProvider, SwapState> {
val fromToken = fromSwapCurrencyStatus.currency
val toToken = toSwapCurrencyStatus.currency
val includeFeeInAmount = getIncludeFeeInAmountInternal(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
feeValue = BigDecimal.ZERO,
)
val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmountInternal.Included) {
includeFeeInAmount.amountSubtractFee
} else {
amount
}
// Always request the user-entered amount. The real balance/fee decision is deferred to the fee
// selector (`computeBalanceStatus` / `applySwapFee`), which correctly handles gasless (token) fee
// payment even when the native coin balance is zero. Do NOT derive the quote amount from the native
// balance here — that discards the entered amount ([REDACTED_TASK_KEY] regression: CEX always sent max).
val quotes = repository.findBestQuote(
userWallet = fromSwapCurrencyStatus.userWallet,
fromContractAddress = fromToken.getContractAddress(),
fromNetwork = fromToken.network.rawId,
toContractAddress = toToken.getContractAddress(),
toNetwork = toToken.network.rawId,
fromAmount = amountToRequest.toStringWithRightOffset(),
fromAmount = amount.toStringWithRightOffset(),
fromDecimals = amount.decimals,
toDecimals = toToken.decimals,
providerId = provider.providerId,
rateType = RateType.FLOAT,
)
val quoteBalanceStatus = if (includeFeeInAmount == IncludeFeeInAmountInternal.BalanceNotEnough) {
SwapBalanceStatus.InsufficientAmount
} else {
SwapBalanceStatus.Pending // fee not resolved yet
}
return provider to getQuotesState(
provider = provider,
quoteDataModel = quotes,
@ -564,7 +541,7 @@ internal class SwapInteractorImpl @Inject constructor(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
isAllowedToSpend = true,
quoteBalanceStatus = quoteBalanceStatus,
quoteBalanceStatus = SwapBalanceStatus.Pending,
)
}
@ -1809,8 +1786,8 @@ internal class SwapInteractorImpl @Inject constructor(
* same-currency-token path: balance check on the from-token's own balance.
* - Otherwise native-fee branch via [getIncludeFeeInAmountForNative].
*
* Used both by [loadCexQuoteData] (with `feeValue = ZERO` at quote stage) and by
* [computeBalanceStatus] (with the actual fee once the selector resolves).
* Used by [computeBalanceStatus] with the actual fee once the fee selector resolves. The quote stage
* ([manageCex]) no longer consults this it always requests the user-entered amount.
*/
private suspend fun getIncludeFeeInAmountInternal(
fromSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -383,11 +383,6 @@ class DexSwapFeeCalculator(
derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value,
)
// if native balance is zero - we can't calculate fee
if (nativeBalance.signum() == 0) {
raise(GetFeeError.UnknownError)
}
val txAmountValue = transaction.txValue ?: error("unable to get txValue")
val amountToSend = if (permissionState is PermissionDataState.PermissionSettings) {
transaction.fromAmount.value.convertToSdkAmount(fromSwapCurrencyStatus.status)

View file

@ -20,6 +20,7 @@ import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
@ -28,6 +29,7 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.Ignore
import org.junit.jupiter.api.BeforeEach
@ -720,6 +722,337 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
}
}
/**
* [REDACTED_TASK_KEY]: the CEX quote stage must request the **user-entered** amount as `fromAmount`, regardless of
* the native-coin balance or `reduceBalanceBy`. A prior fix derived the quote amount from the native
* balance (`nativeBalance - reduceBalanceBy`), which discarded the entered amount and made CEX always
* quote the max balance (and, for tokens, sent the native balance under the token's decimals). The real
* balance/fee decision is deferred to the fee selector, so the quote status is always `Pending`.
*/
@Nested
inner class CexQuoteAmount {
@Test
fun `should request the entered amount for a coin with non-zero native balance`() = runTest {
// Given — coin balance 10, native balance 10 (base stub); user enters 0.014 (the reported case)
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
decimals = 18,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val fromAmountSlot = slot<String>()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = capture(fromAmountSlot),
fromDecimals = any(),
toDecimals = any(),
providerId = cexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(cexProvider),
amountToSwap = "0.014",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — fromAmount is the entered 0.014 (0.014 * 1e18), NOT the full balance
assertThat(fromAmountSlot.isCaptured).isTrue()
assertThat(fromAmountSlot.captured).isEqualTo("14000000000000000")
assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending)
}
@Test
fun `should ignore reduceBalanceBy when building the CEX quote fromAmount`() = runTest {
// Given — reduceBalanceBy must NOT affect the CEX quote amount anymore
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
decimals = 18,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val fromAmountSlot = slot<String>()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = capture(fromAmountSlot),
fromDecimals = any(),
toDecimals = any(),
providerId = cexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When
sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(cexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal("2"),
)
// Then — still the entered 1.0 * 1e18, unaffected by reduceBalanceBy
assertThat(fromAmountSlot.captured).isEqualTo("1000000000000000000")
}
@Test
fun `should request the entered amount for a coin with zero native balance`() = runTest {
// Given — native balance ZERO must not block or override the entered amount
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
decimals = 18,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val fromAmountSlot = slot<String>()
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = capture(fromAmountSlot),
fromDecimals = any(),
toDecimals = any(),
providerId = cexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(cexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — 1.0 * 1e18, status Pending
assertThat(fromAmountSlot.captured).isEqualTo("1000000000000000000")
val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending)
}
@Test
fun `should request the entered token amount with token decimals for a token with non-zero native balance`() =
runTest {
// Given — token (6 decimals) balance 100, native ETH balance 10 (base stub); user enters 5.
// The quote must send 5 in token units, NOT the native balance under token decimals.
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
amount = BigDecimal("100"),
decimals = 6,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val fromAmountSlot = slot<String>()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = capture(fromAmountSlot),
fromDecimals = any(),
toDecimals = any(),
providerId = cexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(cexProvider),
amountToSwap = "5",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — 5 * 1e6 (token decimals), NOT 10 (native balance)
assertThat(fromAmountSlot.captured).isEqualTo("5000000")
val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending)
}
@Test
fun `should not block a token swap with zero native balance (gasless)`() = runTest {
// Given — the original [REDACTED_TASK_KEY] case: token with zero native (ETH) balance, gasless supported.
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
amount = BigDecimal("100"),
decimals = 6,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val fromAmountSlot = slot<String>()
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = capture(fromAmountSlot),
fromDecimals = any(),
toDecimals = any(),
providerId = cexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(cexProvider),
amountToSwap = "5",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — entered token amount is quoted and status is Pending (not InsufficientAmount)
assertThat(fromAmountSlot.captured).isEqualTo("5000000")
val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending)
}
@Test
fun `should request the full entered balance for a coin when max is tapped`() = runTest {
// Given — "Max" sets the entered amount to the full coin balance (10). The native balance stub is
// deliberately different (3) so a regression to the old `nativeBalance - reduceBalanceBy` logic
// would flip the asserted value (3e18) instead of the entered 10e18.
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
decimals = 18,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val fromAmountSlot = slot<String>()
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("3")
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = capture(fromAmountSlot),
fromDecimals = any(),
toDecimals = any(),
providerId = cexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When — user taps Max: entered amount == full coin balance
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(cexProvider),
amountToSwap = "10",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — full entered balance 10 * 1e18, NOT the native balance (3); no quote-stage fee subtraction
assertThat(fromAmountSlot.captured).isEqualTo("10000000000000000000")
val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending)
}
@Test
fun `should request the full entered token balance when max is tapped`() = runTest {
// Given — token (6 decimals) balance 100, native ETH balance 10 (base stub). "Max" enters 100.
// native (10) naturally differs from the token balance (100), so a regression to the native-balance
// logic would send 10 (as "10000000") instead of the entered 100 (as "100000000").
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0xToken",
isCoin = false,
amount = BigDecimal("100"),
decimals = 6,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val fromAmountSlot = slot<String>()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = capture(fromAmountSlot),
fromDecimals = any(),
toDecimals = any(),
providerId = cexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When — user taps Max: entered amount == full token balance
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(cexProvider),
amountToSwap = "100",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — full entered token balance 100 * 1e6, NOT the native balance (10)
assertThat(fromAmountSlot.captured).isEqualTo("100000000")
val loaded = (result[cexProvider] ?: error("state must not be null")) as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.balanceStatus).isEqualTo(SwapBalanceStatus.Pending)
}
}
@Nested
inner class MixedProviderDispatch {

View file

@ -124,29 +124,88 @@ internal class DexSwapFeeCalculatorTest {
}
// -------------------------------------------------------------------------
// EVM zero-balance short-circuit
// EVM zero-balance no longer short-circuits (guard removed)
//
// Previously a zero native balance raised UnknownError *before* any fee call. That guard was
// removed, so a zero-balance quote must still surface a fee: when the tx amount fits the (zero)
// balance the normal getFeeUseCase path runs; when it does not, the balance check throws and the
// calculator falls back to getEthSpecificFeeUseCase via the IllegalStateException branch.
// -------------------------------------------------------------------------
@Test
fun `EVM DEX swap with native balance ZERO returns Left UnknownError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDex(txValue = "0")
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO
fun `EVM DEX swap with native balance ZERO no longer short-circuits and computes fee via getFeeUseCase`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
// txValue "0" → amountToSend 0, so `nativeBalance(0) < 0` is false and the main path runs.
val transaction = buildDex(txValue = "0")
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
val result = sut.calculate(fromStatus, transaction)
val result = sut.calculate(fromStatus, transaction)
assertThat(result.isLeft()).isTrue()
result.onLeft { assertThat(it).isEqualTo(GetFeeError.UnknownError) }
// getFeeUseCase should not have been called because balance check short-circuits first.
// Use a more permissive verify to avoid clashing with the other overload signatures.
coVerify(exactly = 0) {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any<TransactionData>(),
)
// The removed guard means the fee is now computed instead of raising UnknownError.
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any<TransactionData>(),
)
}
coVerify(exactly = 0) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
}
}
@Test
fun `EVM DEX swap with native balance ZERO falls back to getEthSpecificFeeUseCase when txValue exceeds balance`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val gas = BigInteger.valueOf(120_000L)
// txValue 0.001 ETH > zero balance → `nativeBalance < amountToSend` throws → gas fallback.
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO
coEvery {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
} returns TransactionFee.Choosable(
minimum = ethLegacyFee(),
normal = ethLegacyFee(),
priority = ethLegacyFee(),
).right()
val result = sut.calculate(fromStatus, transaction)
// Zero balance now falls back instead of raising UnknownError up-front.
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = gas,
gasPrice = any(),
)
}
// The balance check throws before the main fee call, so getFeeUseCase is never reached.
coVerify(exactly = 0) {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any<TransactionData>(),
)
}
}
}
// -------------------------------------------------------------------------
// EVM IllegalStateException → fallback to GetEthSpecificFeeUseCase

View file

@ -2083,6 +2083,7 @@ internal class SwapModel @Inject constructor(
if (provider != null && swapState != null && isNotNullCurrency) {
modelScope.launch(dispatchers.default) {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus)
feeSelectorReloadTrigger.triggerUpdate()
}
analyticsEventHandler.send(SwapEvents.ProviderChosen(provider))

View file

@ -12,6 +12,7 @@ interface TokenDetailsComponent : ComposableContentComponent {
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val navigationAction: NavigationAction? = null,
val shouldShowMarketBlock: Boolean = true,
)
interface Factory : ComponentFactory<Params, TokenDetailsComponent>

View file

@ -23,12 +23,12 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreen
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.TransferBottomSheetComponent
import com.tangem.features.rating.RatingComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.rating.RatingComponent
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
@ -113,12 +113,14 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
},
)
private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams ->
tokenMarketBlockComponentFactory.create(
appComponentContext = child("tokenMarketBlockComponent"),
params = tokenMarketParams,
)
}
private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()
?.takeIf { params.shouldShowMarketBlock }
?.let { tokenMarketParams ->
tokenMarketBlockComponentFactory.create(
appComponentContext = child("tokenMarketBlockComponent"),
params = tokenMarketParams,
)
}
private val yieldSupplyComponent = yieldSupplyComponentFactory.create(
context = child("tokenYieldSupplyComponent"),

View file

@ -23,6 +23,7 @@ import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.analytics.models.event.TransferAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -368,6 +369,7 @@ internal class TokenDetailsModel @Inject constructor(
actions = state.states,
networkSource = networkSource,
clickIntents = this@TokenDetailsModel,
analyticsEventHandler = analyticsEventsHandler,
onActionDispatched = bottomSheetNavigation::dismiss,
),
)
@ -547,6 +549,13 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onAddFundsClick() {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonAddFunds(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
derivationIndex = getAccountIndexOrNull(),
),
)
bottomSheetNavigation.activate(
TokenDetailsBottomSheetConfig.AddFunds(
userWalletId = userWalletId,
@ -556,15 +565,25 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onTransferClick() {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonTransfer(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
derivationIndex = getAccountIndexOrNull(),
),
)
val amount = cryptoCurrencyStatus?.value?.amount
if (amount == null || amount.signum() <= 0) {
uiMessageSender.send(
message = SnackbarMessage(
message = resourceReference(R.string.token_button_unavailability_reason_empty_balance_send),
handleUnavailabilityReason(
unavailabilityReason = ScenarioUnavailabilityReason.EmptyBalance(
withdrawalScenario = ScenarioUnavailabilityReason.WithdrawalScenario.SEND,
),
)
return
}
analyticsEventsHandler.send(
TransferAnalyticsEvent.MethodScreenOpened(source = AnalyticsParam.ScreensSources.Token),
)
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.Transfer)
}

View file

@ -13,12 +13,7 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ModelScoped
@ -51,14 +46,14 @@ internal class TokenDetailsStateController @Inject constructor() {
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
addFundsButton = TangemButtonUM(
text = resourceReference(R.string.tangempay_card_details_add_funds),
text = resourceReference(R.string.actionbutton_addfunds_title),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
swapButton = TangemButtonUM(
text = resourceReference(R.string.common_swap),
text = resourceReference(R.string.actionbutton_swap_title),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_exchange_default_24,
tintReference = { TangemTheme.colors2.graphic.neutral.quaternary },
@ -68,7 +63,7 @@ internal class TokenDetailsStateController @Inject constructor() {
type = TangemButtonType.Secondary,
),
transferButton = TangemButtonUM(
text = resourceReference(R.string.common_transfer),
text = resourceReference(R.string.actionbutton_transfer_title),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24),
onClick = { },
isEnabled = true,

View file

@ -1,5 +1,7 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.TransferAnalyticsEvent
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.TokenActionsState
@ -13,6 +15,7 @@ internal class UpdateTransferTransformer(
private val actions: List<TokenActionsState.ActionState>,
private val networkSource: StatusSource,
private val clickIntents: TokenDetailsClickIntents,
private val analyticsEventHandler: AnalyticsEventHandler,
private val onActionDispatched: () -> Unit,
) : Transformer<TokenDetailsUM> {
@ -28,6 +31,7 @@ internal class UpdateTransferTransformer(
isLoading = action.unavailabilityReason.isOutdatedLoading(),
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
analyticsEventHandler.send(TransferAnalyticsEvent.ButtonSend())
onActionDispatched()
clickIntents.onSendClick(action.unavailabilityReason)
},
@ -38,6 +42,7 @@ internal class UpdateTransferTransformer(
isLoading = action.unavailabilityReason.isLoading,
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
analyticsEventHandler.send(TransferAnalyticsEvent.ButtonSwap())
onActionDispatched()
clickIntents.onSwapFromClick(action.unavailabilityReason)
},
@ -53,6 +58,7 @@ internal class UpdateTransferTransformer(
isLoading = false,
isEnabled = true,
onClick = {
analyticsEventHandler.send(TransferAnalyticsEvent.ButtonSwapAndSend())
onActionDispatched()
clickIntents.onSwapAndSendClick(it.unavailabilityReason)
},
@ -63,6 +69,7 @@ internal class UpdateTransferTransformer(
isLoading = action.unavailabilityReason.isOutdatedLoading(),
isEnabled = action.unavailabilityReason == ScenarioUnavailabilityReason.None,
onClick = {
analyticsEventHandler.send(TransferAnalyticsEvent.ButtonSell())
onActionDispatched()
clickIntents.onSellClick(action.unavailabilityReason)
},

View file

@ -14,11 +14,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -35,11 +35,9 @@ import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.button.action.ActionButtons
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
@ -47,6 +45,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBala
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.features.tokendetails.impl.R
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
private val CurrencyIconSize: Dp = 70.dp
private val NetworkBadgeSize: Dp = 24.dp
@ -90,16 +89,25 @@ internal fun TokenDetailsBalanceBlock(
}
if (!balanceBlockUM.isBalanceZeroContent()) {
SpacerH(TangemTheme.dimens2.x10)
val hapticManager = LocalHapticManager.current
val buttons = remember(
balanceBlockUM.addFundsButton,
balanceBlockUM.swapButton,
balanceBlockUM.transferButton,
hapticManager,
) {
persistentListOf(
balanceBlockUM.addFundsButton,
balanceBlockUM.swapButton,
balanceBlockUM.transferButton,
)
).map { button ->
button.copy(
onClick = {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
button.onClick()
},
)
}.toPersistentList()
}
ActionButtons(buttons = buttons)
}

View file

@ -1,6 +1,8 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.TransferAnalyticsEvent
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
@ -24,6 +26,7 @@ import org.junit.jupiter.api.Test
class UpdateTransferTransformerTest {
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val onActionDispatched: () -> Unit = mockk(relaxed = true)
@Test
@ -112,6 +115,7 @@ class UpdateTransferTransformerTest {
// THEN
verifyOrder {
analyticsEventHandler.send(ofType<TransferAnalyticsEvent.ButtonSend>())
onActionDispatched.invoke()
clickIntents.onSendClick(ScenarioUnavailabilityReason.None)
}
@ -130,6 +134,7 @@ class UpdateTransferTransformerTest {
// THEN
verifyOrder {
analyticsEventHandler.send(ofType<TransferAnalyticsEvent.ButtonSell>())
onActionDispatched.invoke()
clickIntents.onSellClick(ScenarioUnavailabilityReason.None)
}
@ -225,6 +230,7 @@ class UpdateTransferTransformerTest {
// THEN
verifyOrder {
analyticsEventHandler.send(ofType<TransferAnalyticsEvent.ButtonSwap>())
onActionDispatched.invoke()
clickIntents.onSwapFromClick(ScenarioUnavailabilityReason.None)
}
@ -347,6 +353,7 @@ class UpdateTransferTransformerTest {
// Assert
verifyOrder {
analyticsEventHandler.send(ofType<TransferAnalyticsEvent.ButtonSwapAndSend>())
onActionDispatched.invoke()
clickIntents.onSwapAndSendClick(ScenarioUnavailabilityReason.None)
}
@ -405,6 +412,7 @@ class UpdateTransferTransformerTest {
// THEN
verify(exactly = 0) { onActionDispatched.invoke() }
verify(exactly = 0) { clickIntents.onSendClick(any()) }
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
private fun createTransformer(
@ -414,6 +422,7 @@ class UpdateTransferTransformerTest {
actions = actions,
networkSource = networkSource,
clickIntents = clickIntents,
analyticsEventHandler = analyticsEventHandler,
onActionDispatched = onActionDispatched,
)

View file

@ -125,6 +125,7 @@ internal class WalletClickIntents @Inject constructor(
}
fun onTransferClick(userWalletId: UserWalletId) {
analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonTransfer())
router.openTransfer(userWalletId)
}

View file

@ -55,7 +55,7 @@ internal sealed class WalletActionButtons(
override val onClick: () -> Unit,
override val isEnabled: Boolean,
) : WalletActionButtons(
text = resourceReference(R.string.common_add_funds),
text = resourceReference(R.string.actionbutton_addfunds_title),
iconRes = R.drawable.ic_arrow_down_24,
)
@ -63,7 +63,7 @@ internal sealed class WalletActionButtons(
override val onClick: () -> Unit,
override val isEnabled: Boolean,
) : WalletActionButtons(
text = resourceReference(R.string.common_swap),
text = resourceReference(R.string.actionbutton_swap_title),
iconRes = R.drawable.ic_exchange_default_24,
)
@ -79,7 +79,7 @@ internal sealed class WalletActionButtons(
override val onClick: () -> Unit,
override val isEnabled: Boolean,
) : WalletActionButtons(
text = resourceReference(R.string.common_transfer),
text = resourceReference(R.string.actionbutton_transfer_title),
iconRes = R.drawable.ic_arrow_up_24,
)
}

View file

@ -5,6 +5,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import com.tangem.core.ui.ds.button.TangemButton
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
@ -31,13 +33,19 @@ internal fun LazyListScope.organizeTokens2(state: WalletUM, itemModifier: Modifi
key = "OrganizeTokensButton",
contentType = "OrganizeTokensButton",
) {
val hapticManager = LocalHapticManager.current
val testTag = if (organizeButton.text == resourceReference(R.string.main_add_and_manage_tokens)) {
MainScreenTestTags.ADD_AND_MANAGE_BUTTON
} else {
MainScreenTestTags.ORGANIZE_TOKENS_BUTTON
}
TangemButton(
buttonUM = organizeButton,
buttonUM = organizeButton.copy(
onClick = {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
organizeButton.onClick()
},
),
modifier = itemModifier.testTag(testTag),
)
}

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -37,6 +38,8 @@ import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.res.LocalHapticManager
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.MainScreenTestTags
@ -47,6 +50,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditiona
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
private const val MIN_SCALE = 0.75f
private const val MAX_SCALE = 1f
@ -64,6 +68,17 @@ internal fun WalletBalance(
val alpha = 1f - collapsedFraction
val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE)
val density = LocalDensity.current
val hapticManager = LocalHapticManager.current
val hapticButtons = remember(buttons, hapticManager) {
buttons.map { button ->
button.copy(
onClick = {
hapticManager.perform(TangemHapticEffect.View.ContextClick)
button.onClick()
},
)
}.toImmutableList()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@ -99,7 +114,7 @@ internal fun WalletBalance(
}
}
SpacerH(TangemTheme.dimens2.x2)
ActionButtons(buttons, modifier = Modifier.fillMaxWidth())
ActionButtons(buttons = hapticButtons, modifier = Modifier.fillMaxWidth())
SpacerH(TangemTheme.dimens2.x6)
}
}

View file

@ -28,10 +28,8 @@ import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedS
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalRootBackgroundColor
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.haptic.TangemHapticEffect
import com.tangem.core.ui.res.*
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.WalletPreviewDataLegacy
@ -58,6 +56,7 @@ internal fun WalletTopBar(
isBalanceHidden: Boolean,
behavior: TangemCollapsingAppBarBehavior,
) {
val hapticManager = LocalHapticManager.current
Surface(
color = Color.Unspecified,
contentColor = Color.Unspecified,
@ -95,7 +94,14 @@ internal fun WalletTopBar(
) {
topBarConfig.endActions.forEach { action ->
TangemTopBarActionContent(
action,
action.copy(
onClick = action.onClick?.let { onClick ->
{
hapticManager.perform(TangemHapticEffect.View.ContextClick)
onClick()
}
},
),
modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON),
)
}