Updated on 2026-08-14
This commit is contained in:
commit
997194b3fb
92 changed files with 2454 additions and 391 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -125,7 +125,9 @@ private fun OnrampAmountField(amountField: AmountFieldModel, currencyCode: Strin
|
|||
)
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
requester.requestFocus()
|
||||
if (!amountField.isError) {
|
||||
requester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 = "$",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ internal class WalletClickIntents @Inject constructor(
|
|||
}
|
||||
|
||||
fun onTransferClick(userWalletId: UserWalletId) {
|
||||
analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonTransfer())
|
||||
router.openTransfer(userWalletId)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue