Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-05 19:19:24 +03:00
commit 24ef1d4ed9
120 changed files with 6250 additions and 675 deletions

View file

@ -11,9 +11,11 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
@ -30,6 +32,7 @@ import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalCanScrollBackward
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.commonfeatures.impl.R
@ -46,51 +49,59 @@ internal fun PortfolioSelectorContent(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
) {
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
val lazyListState = rememberLazyListState()
CompositionLocalProvider(
LocalCanScrollBackward provides
lazyListState.canScrollBackward,
) {
val items = state.items
itemsIndexed(
items = items,
key = { _, item -> item.id },
) { index, item ->
val previewItem = items.getOrNull(index.dec())
val offsetModifier = when {
previewItem == null -> Modifier
item is PortfolioSelectorItemUM.GroupTitle -> Modifier.padding(
top = TangemTheme.dimens.spacing16,
)
else -> Modifier.padding(
top = TangemTheme.dimens.spacing8,
)
}
LazyColumn(
state = lazyListState,
modifier = modifier,
contentPadding = contentPadding,
) {
val items = state.items
itemsIndexed(
items = items,
key = { _, item -> item.id },
) { index, item ->
val previewItem = items.getOrNull(index.dec())
val offsetModifier = when {
previewItem == null -> Modifier
item is PortfolioSelectorItemUM.GroupTitle -> Modifier.padding(
top = TangemTheme.dimens.spacing16,
)
else -> Modifier.padding(
top = TangemTheme.dimens.spacing8,
)
}
val portfolioShape = RoundedCornerShape(TangemTheme.dimens.radius14)
val border = BorderStroke(
width = 1.dp,
color = TangemTheme.colors.text.accent,
)
val portfolioShape = RoundedCornerShape(TangemTheme.dimens.radius14)
val border = BorderStroke(
width = 1.dp,
color = TangemTheme.colors.text.accent,
)
when (item) {
is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow(
state = item.item,
modifier = offsetModifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size68)
.clip(portfolioShape)
.background(TangemTheme.colors.background.action)
.conditional(item.isSelected) { border(border, portfolioShape) }
.clickable(enabled = item.item.isEnabled, onClick = item.item.onClick)
.padding(all = TangemTheme.dimens.spacing12)
.conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) },
)
is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow(
model = item,
modifier = offsetModifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
)
when (item) {
is PortfolioSelectorItemUM.Portfolio -> UserWalletItemRow(
state = item.item,
modifier = offsetModifier
.fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size68)
.clip(portfolioShape)
.background(TangemTheme.colors.background.action)
.conditional(item.isSelected) { border(border, portfolioShape) }
.clickable(enabled = item.item.isEnabled, onClick = item.item.onClick)
.padding(all = TangemTheme.dimens.spacing12)
.conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) },
)
is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow(
model = item,
modifier = offsetModifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
)
}
}
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -33,6 +34,7 @@ import com.tangem.core.ui.ds.row.TangemRowContainer
import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.LocalCanScrollBackward
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -48,20 +50,41 @@ internal fun PortfolioSelectorContentV2(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
) {
LazyColumn(
modifier = modifier,
contentPadding = contentPadding,
val lazyListState = rememberLazyListState()
CompositionLocalProvider(
LocalCanScrollBackward provides
lazyListState.canScrollBackward,
) {
val items = state.items
itemsIndexed(
items = items,
key = { _, item -> item.id },
) { index, item ->
when (item) {
is PortfolioSelectorItemUM.Portfolio ->
PortfolioSelectorItem(
state = item.item,
LazyColumn(
state = lazyListState,
modifier = modifier,
contentPadding = contentPadding,
) {
val items = state.items
itemsIndexed(
items = items,
key = { _, item -> item.id },
) { index, item ->
when (item) {
is PortfolioSelectorItemUM.Portfolio ->
PortfolioSelectorItem(
state = item.item,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
radius = TangemTheme.dimens2.x5,
addDefaultPadding = false,
)
.clickable(enabled = item.item.isEnabled, onClick = item.item.onClick)
.conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) },
)
is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow(
model = item,
modifier = Modifier
.fillMaxWidth()
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
@ -69,22 +92,9 @@ internal fun PortfolioSelectorContentV2(
radius = TangemTheme.dimens2.x5,
addDefaultPadding = false,
)
.clickable(enabled = item.item.isEnabled, onClick = item.item.onClick)
.conditional(!item.item.isEnabled) { alpha(DISABLED_WALLET_ALPHA) },
.padding(horizontal = TangemTheme.dimens.spacing16),
)
is PortfolioSelectorItemUM.GroupTitle -> WalletNameRow(
model = item,
modifier = Modifier
.fillMaxWidth()
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
radius = TangemTheme.dimens2.x5,
addDefaultPadding = false,
)
.padding(horizontal = TangemTheme.dimens.spacing16),
)
}
}
}
}

View file

@ -96,7 +96,7 @@ internal class CreateWalletSelectionModel @Inject constructor(
return
}
router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.AddNewWallet.value))
router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.AddNewWallet))
}
private fun onHardwareWalletClick() {

View file

@ -164,7 +164,7 @@ internal class CreateWalletStartModel @Inject constructor(
return
}
router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.CreateWalletIntro.value))
router.push(AppRoute.CreateMobileWallet(AnalyticsParam.ScreensSources.CreateWalletIntro))
}
private fun onBuyClick() {

View file

@ -194,7 +194,7 @@ internal class CreateWalletStartModelTest {
verify {
router.push(
route = AppRoute.CreateMobileWallet(
source = AnalyticsParam.ScreensSources.CreateWalletIntro.value,
source = AnalyticsParam.ScreensSources.CreateWalletIntro,
),
onComplete = any(),
)

View file

@ -87,7 +87,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
getUserCountryUseCase: GetUserCountryUseCase,
paramsContainer: ParamsContainer,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val designFeatureToggles: DesignFeatureToggles,
private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
@ -247,6 +247,7 @@ internal class MarketsTokenDetailsModel @Inject constructor(
val state = MutableStateFlow(
MarketsTokenDetailsUM(
tokenName = params.token.name,
symbol = params.token.symbol,
priceText = params.token.tokenQuotes.currentPrice.format {
fiat(
fiatCurrencyCode = currentAppCurrency.value.code,

View file

@ -21,6 +21,7 @@ import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.features.feed.components.FeedEntryChildFactory
import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation
@ -138,7 +139,9 @@ private fun EntryContentV2(
},
)
.hazeSourceTangem(zIndex = 0f, state = hazeState),
contentPadding = PaddingValues(top = topBarHeight),
contentPadding = PaddingValues(
top = if (isOpenedInBottomSheet) topBarHeight else TangemTheme.dimens2.x2_5,
),
bottomSheetState = bottomSheetState,
)
}

View file

@ -266,13 +266,13 @@ private fun LazyListScope.bestOpportunitiesItemsV2(state: EarnBestOpportunitiesU
is EarnBestOpportunitiesUM.Empty -> {
item(key = "best_opportunities_empty") {
SpacerH(12.dp)
BestOpportunitiesEmpty() // TODO in [REDACTED_TASK_KEY]
BestOpportunitiesEmpty()
}
}
is EarnBestOpportunitiesUM.EmptyFiltered -> {
item(key = "best_opportunities_empty_filtered") {
SpacerH(12.dp)
BestOpportunitiesEmptyFiltered(onClearFilterClick = state.onClearFilterClick) // TODO in [REDACTED_TASK_KEY]
BestOpportunitiesEmptyFiltered(onClearFilterClick = state.onClearFilterClick)
}
}
is EarnBestOpportunitiesUM.Content -> {
@ -305,7 +305,7 @@ private fun LazyListScope.bestOpportunitiesItemsV2(state: EarnBestOpportunitiesU
color = TangemTheme.colors2.surface.level3,
shape = RoundedCornerShape(TangemTheme.dimens2.x5),
)
.padding(vertical = 142.dp, horizontal = 114.dp),
.padding(vertical = 142.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = state.onRetryClicked)

View file

@ -85,7 +85,7 @@ private fun BestOpportunitiesEmptyV2(modifier: Modifier = Modifier) {
Text(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x8),
text = stringResourceSafe(R.string.earn_empty),
style = TangemTheme.typography2.bodyRegular14,
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.secondary,
textAlign = TextAlign.Center,
)

View file

@ -44,7 +44,7 @@ private fun BestOpportunitiesEmptyFilteredV2(onClearFilterClick: () -> Unit, mod
) {
Text(
text = stringResourceSafe(R.string.earn_no_results),
style = TangemTheme.typography2.bodyRegular14,
style = TangemTheme.typography2.subheadlineMedium14,
color = TangemTheme.colors2.text.neutral.secondary,
)
SpacerH(TangemTheme.dimens2.x2)

View file

@ -175,13 +175,15 @@ private fun NetworksTypesBlock(
overflow = TextOverflow.Ellipsis,
)
TangemCheckbox(
modifier = Modifier
.padding(start = 8.dp)
.layoutId(layoutId = TangemRowLayoutId.TAIL),
isChecked = item.isSelected,
onCheckedChange = { onOptionClick(item) },
)
if (item.isSelected) {
TangemCheckbox(
modifier = Modifier
.padding(start = 8.dp)
.layoutId(layoutId = TangemRowLayoutId.TAIL),
isChecked = true,
onCheckedChange = { onOptionClick(item) },
)
}
}
}
}
@ -233,13 +235,15 @@ private fun SpecificNetworksBlock(
overflow = TextOverflow.Ellipsis,
)
TangemCheckbox(
modifier = Modifier
.padding(start = 8.dp)
.layoutId(layoutId = TangemRowLayoutId.TAIL),
isChecked = item.isSelected,
onCheckedChange = { onOptionClick(item) },
)
if (item.isSelected) {
TangemCheckbox(
modifier = Modifier
.padding(start = 8.dp)
.layoutId(layoutId = TangemRowLayoutId.TAIL),
isChecked = true,
onCheckedChange = { onOptionClick(item) },
)
}
}
}
}

View file

@ -115,7 +115,8 @@ private fun EarnListItemV2(item: EarnListItemUM, modifier: Modifier = Modifier)
tangemIconUM = TangemIconUM.Currency(item.currencyIconState),
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.HEAD)
.padding(end = TangemTheme.dimens2.x2),
.padding(end = TangemTheme.dimens2.x2)
.size(TangemTheme.dimens2.x10),
)
TokenTitle(

View file

@ -15,10 +15,10 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.ds.opportunities.OpportunitiesBG
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.opportunities.OpportunitiesBG
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.*
@ -53,10 +53,10 @@ private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier
) {
Column(modifier = Modifier.padding(12.dp)) {
CurrencyIcon(
modifier = Modifier.size(32.dp),
state = item.currencyIconState,
shouldDisplayNetwork = true,
networkBadgeSize = 12.dp,
networkBadgeSize = TangemTheme.dimens2.x4,
iconSize = TangemTheme.dimens2.x10,
networkBadgeBackground = TangemTheme.colors.background.action,
)

View file

@ -118,12 +118,7 @@ private fun Content(
contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()),
) {
item("header") {
Header(
state = state,
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
)
Header(state = state)
}
item {
if (isRedesignEnabled) SpacerH(TangemTheme.dimens2.x3) else SpacerH16()
@ -202,15 +197,33 @@ internal fun MarketsTokenDetailsTopBar(
}
@Composable
private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) {
private fun Header(state: MarketsTokenDetailsUM) {
if (LocalRedesignEnabled.current) {
HeaderV2(
modifier = Modifier
.padding(TangemTheme.dimens2.x4)
.fillMaxWidth(),
state = state,
)
} else {
HeaderV1(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
state = state,
)
}
}
@Composable
private fun HeaderV1(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
TokenPriceText(
TokenPriceTextV1(
price = state.priceText,
priceAnnotated = state.priceAnnotated,
triggerPriceChange = state.triggerPriceChange,
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
@ -240,23 +253,55 @@ private fun Header(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier)
}
@Composable
private fun TokenPriceText(
price: String,
triggerPriceChange: StateEvent<PriceChangeType>,
priceAnnotated: TextReference,
modifier: Modifier = Modifier,
) {
if (LocalRedesignEnabled.current) {
TokenPriceTextV2(
priceAnnotated = priceAnnotated,
triggerPriceChange = triggerPriceChange,
modifier = modifier,
)
} else {
TokenPriceTextV1(
price = price,
triggerPriceChange = triggerPriceChange,
modifier = modifier,
private fun HeaderV2(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Text(
text = state.tokenName,
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
)
Text(
text = state.symbol,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.tertiary,
)
}
SpacerH(TangemTheme.dimens2.x1)
TokenPriceTextV2(
priceAnnotated = state.priceAnnotated,
triggerPriceChange = state.triggerPriceChange,
)
SpacerH(TangemTheme.dimens2.x4)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) {
Text(
text = state.dateTimeText.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.primary,
)
if (state.priceChangePercentText != null) {
PriceChangeInPercent(
valueInPercent = state.priceChangePercentText,
type = state.priceChangeType,
textStyle = TangemTheme.typography2.captionMedium12,
)
}
}
}
SpacerW4()
CoinIcon(
modifier = Modifier.requiredSize(70.dp),
url = state.iconUrl,
alpha = 1f,
colorFilter = null,
fallbackResId = R.drawable.ic_custom_token_44,
)
}
}
@ -323,9 +368,9 @@ private fun TokenPriceTextV2(
text = priceAnnotated.resolveAnnotatedReference(),
modifier = modifier,
color = color.value,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography2.headingBold34.fontSize),
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography2.titleRegular44.fontSize),
maxLines = 1,
style = TangemTheme.typography2.headingBold34,
style = TangemTheme.typography2.titleRegular44,
)
}

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -49,6 +50,7 @@ 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.res.TangemThemePreviewRedesign
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.state.ExchangeItemUM
import com.tangem.features.feed.ui.market.detailed.state.ExchangesBottomSheetContent
@ -273,7 +275,7 @@ private fun ErrorV2(content: ExchangesBottomSheetContent.Error, modifier: Modifi
text = stringResourceSafe(id = content.message),
color = TangemTheme.colors2.text.neutral.tertiary,
textAlign = TextAlign.Center,
style = TangemTheme.typography2.bodyRegular14,
style = TangemTheme.typography2.subheadlineMedium14,
)
SpacerH(8.dp)
@ -369,6 +371,7 @@ private fun ExchangeItemRowPlaceholder(modifier: Modifier = Modifier) {
.layoutId(TangemRowLayoutId.HEAD),
)
RectangleShimmer(
radius = TangemTheme.dimens2.x25,
modifier = Modifier
.width(108.dp)
.height(20.dp)
@ -376,6 +379,7 @@ private fun ExchangeItemRowPlaceholder(modifier: Modifier = Modifier) {
.layoutId(TangemRowLayoutId.START_TOP),
)
RectangleShimmer(
radius = TangemTheme.dimens2.x25,
modifier = Modifier
.width(52.dp)
.height(16.dp)
@ -383,6 +387,7 @@ private fun ExchangeItemRowPlaceholder(modifier: Modifier = Modifier) {
.layoutId(TangemRowLayoutId.START_BOTTOM),
)
RectangleShimmer(
radius = TangemTheme.dimens2.x25,
modifier = Modifier
.width(106.dp)
.height(20.dp)
@ -390,6 +395,7 @@ private fun ExchangeItemRowPlaceholder(modifier: Modifier = Modifier) {
.layoutId(TangemRowLayoutId.END_TOP),
)
RectangleShimmer(
radius = TangemTheme.dimens2.x25,
modifier = Modifier
.width(52.dp)
.height(16.dp)
@ -428,6 +434,25 @@ private fun Preview_ExchangesBottomSheet(
}
}
@Preview
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ExchangesBottomSheetV2(
@PreviewParameter(ExchangesBottomSheetContentProvider::class) content: ExchangesBottomSheetContent,
) {
TangemThemePreviewRedesign {
CompositionLocalProvider(LocalRedesignEnabled provides true) {
ExchangesBottomSheet(
config = TangemBottomSheetConfig(
onDismissRequest = {},
content = content,
isShown = true,
),
)
}
}
}
private class ExchangesBottomSheetContentProvider : CollectionPreviewParameterProvider<ExchangesBottomSheetContent>(
listOf(
ExchangesBottomSheetContent.Loading(exchangesCount = 13),

View file

@ -20,6 +20,7 @@ internal object MarketsTokenDetailsPreview {
val loadingState = MarketsTokenDetailsUM(
tokenName = "Token Name",
symbol = "USDT",
priceText = "$0.00000000324",
dateTimeText = stringReference("Today"),
priceChangePercentText = "52.00%",
@ -55,6 +56,7 @@ internal object MarketsTokenDetailsPreview {
val contentState = MarketsTokenDetailsUM(
tokenName = "Token Name",
symbol = "USDT",
priceText = "$0.00000000324",
dateTimeText = stringReference("Today"),
priceChangePercentText = "52.00%",

View file

@ -14,6 +14,7 @@ import java.math.BigDecimal
internal data class MarketsTokenDetailsUM(
val tokenName: String,
val symbol: String,
val priceText: String,
val priceAnnotated: TextReference,
val iconUrl: String?,

View file

@ -1,11 +1,25 @@
package com.tangem.features.feed.ui.market.list.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.contextmenu.TangemContextMenu
import com.tangem.core.ui.ds.contextmenu.TangemContextMenuCheckboxItem
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.feed.model.market.list.state.SortByMenuUM
import com.tangem.features.feed.model.market.list.state.SortByTypeUM
@ -22,15 +36,61 @@ internal fun SortByMenu(
offset = DpOffset.Zero,
modifier = modifier,
) {
SortByTypeUM.entries.fastForEach { sortType ->
TangemContextMenuCheckboxItem(
title = sortType.text,
isChecked = sortMenuUM.selectedOption == sortType,
onClick = {
sortMenuUM.onOptionClicked(sortType)
onDropdownDismiss()
},
)
SortByTypeUM.entries.fastForEachIndexed { index, sortType ->
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.width(238.dp)
.clickableSingle(
onClick = {
sortMenuUM.onOptionClicked(sortType)
onDropdownDismiss()
},
)
.padding(
vertical = TangemTheme.dimens2.x5,
horizontal = TangemTheme.dimens2.x4,
),
) {
Text(
text = sortType.text.resolveReference(),
style = TangemTheme.typography2.headingSemibold17,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
)
if (sortMenuUM.selectedOption == sortType) {
Box(
modifier = Modifier
.padding(TangemTheme.dimens2.x0_5)
.size(TangemTheme.dimens2.x5)
.background(
color = TangemTheme.colors2.graphic.neutral.primary,
shape = CircleShape,
),
) {
Icon(
painter = rememberVectorPainter(
ImageVector.vectorResource(R.drawable.ic_check_default_24),
),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primaryInverted,
modifier = Modifier
.align(Alignment.Center)
.padding(TangemTheme.dimens2.x0_5)
.size(TangemTheme.dimens2.x4),
)
}
}
}
if (index < SortByTypeUM.entries.size - 1) {
HorizontalDivider(
thickness = 0.5.dp,
color = TangemTheme.colors2.border.neutral.quaternary,
)
}
}
}
}
}

View file

@ -8,8 +8,8 @@ import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.entity.InitScreenLaunchMode
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic.SignedInLegacy
import com.tangem.core.analytics.models.Basic.SignedInLegacy.SignInType
import com.tangem.core.analytics.models.AnalyticsParam.SignInType
import com.tangem.core.analytics.models.Basic.SignedIn
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -20,9 +20,7 @@ import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.message.dialog.Dialogs
import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.card.analytics.ParamCardCurrencyConverter
import com.tangem.domain.card.analytics.Shop
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.SaveWalletError
@ -34,18 +32,18 @@ import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.home.impl.ui.state.HomeUM
import com.tangem.features.home.impl.ui.state.Stories
import com.tangem.features.home.impl.ui.state.getRestrictedStories
import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.utils.logging.TangemLogger
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import java.util.Locale
import javax.inject.Inject
@ -218,19 +216,14 @@ internal class HomeModel @Inject constructor(
}
private suspend fun sendSignedInCardAnalyticsEvent(scanResponse: ScanResponse, isImported: Boolean) {
val currency = ParamCardCurrencyConverter().convert(value = scanResponse.cardTypesResolver)
if (currency != null) {
analyticsEventHandler.send(
SignedInLegacy(
currency = currency,
batch = scanResponse.card.batchId,
signInType = SignInType.Card,
walletsCount = userWalletsListRepository.userWalletsSync().size.toString(),
isImported = isImported,
hasBackup = scanResponse.card.backupStatus?.isActive,
),
)
}
analyticsEventHandler.send(
SignedIn(
signInType = SignInType.Card,
walletsCount = userWalletsListRepository.userWalletsSync().size,
isImported = isImported,
isBackedUp = scanResponse.card.backupStatus?.isActive == true,
),
)
}
private fun setLoading(isLoading: Boolean) {

View file

@ -17,6 +17,7 @@ dependencies {
implementation(projects.domain.wallets.models)
/* Project - Core */
api(projects.core.analytics.models)
implementation(projects.core.decompose)
implementation(projects.core.ui)

View file

@ -1,11 +1,12 @@
package com.tangem.features.hotwallet
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface CreateMobileWalletComponent : ComposableContentComponent {
data class Params(
val source: String,
val source: AnalyticsParam.ScreensSources,
)
interface Factory : ComponentFactory<Params, CreateMobileWalletComponent>

View file

@ -11,6 +11,10 @@ android {
namespace = "com.tangem.features.hotwallet.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Api */
implementation(projects.features.hotWallet.api)
@ -78,4 +82,11 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Test */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -129,13 +129,13 @@ internal class AddExistingWalletImportModel @Inject constructor(
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.Finished(
source = AnalyticsParam.ScreensSources.ImportWallet.value,
source = AnalyticsParam.ScreensSources.ImportWallet,
),
)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
source = AnalyticsParam.ScreensSources.ImportWallet.value,
creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.SeedImport,
source = AnalyticsParam.ScreensSources.ImportWallet,
creationType = AnalyticsParam.WalletCreationType.SeedImport,
seedPhraseLength = mnemonic.mnemonicComponents.size,
passPhraseState = if (passphrase.isNullOrBlank()) {
AnalyticsParam.EmptyFull.Empty

View file

@ -62,7 +62,9 @@ internal class CreateMobileWalletModel @Inject constructor(
init {
trackingContextProxy.addHotWalletContext()
analyticsEventHandler.send(event = OnboardingAnalyticsEvent.Onboarding.Started(source = params.source))
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.Onboarding.Started(source = params.source),
)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.SeedPhrase.CreateMobileScreenOpened(source = params.source),
)
@ -95,11 +97,13 @@ internal class CreateMobileWalletModel @Inject constructor(
saveUserWalletUseCase(userWallet)
analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished(source = params.source))
analyticsEventHandler.send(
OnboardingAnalyticsEvent.Onboarding.Finished(source = params.source),
)
analyticsEventHandler.send(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
source = params.source,
creationType = OnboardingAnalyticsEvent.CreateWallet.WalletCreationType.NewSeed,
creationType = AnalyticsParam.WalletCreationType.NewSeed,
seedPhraseLength = SEED_PHRASE_LENGTH,
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,

View file

@ -11,7 +11,7 @@ internal data class WalletBackupUM(
val onRecoveryPhraseClick: () -> Unit,
val onGoogleDriveClick: () -> Unit,
val onHardwareWalletClick: () -> Unit,
val backedUp: Boolean,
val isBackedUp: Boolean,
)
internal sealed class BackupStatus {

View file

@ -7,10 +7,13 @@ 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.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.components.label.entity.LabelStyle
import com.tangem.core.ui.components.label.entity.LabelUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -19,9 +22,9 @@ import com.tangem.features.hotwallet.WalletBackupComponent
import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus
import com.tangem.features.hotwallet.walletbackup.entity.WalletBackupUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@ -35,6 +38,7 @@ internal class WalletBackupModel @Inject constructor(
private val router: Router,
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
private val uiMessageSender: UiMessageSender,
) : Model() {
private val params: WalletBackupComponent.Params = paramsContainer.require()
@ -59,9 +63,9 @@ internal class WalletBackupModel @Inject constructor(
),
googleDriveStatus = BackupStatus.ComingSoon,
onRecoveryPhraseClick = ::onRecoveryPhraseClick,
onGoogleDriveClick = { },
onGoogleDriveClick = ::onGoogleDriveBackupClick,
onHardwareWalletClick = ::onHardwareWalletClick,
backedUp = false,
isBackedUp = false,
),
)
@ -116,15 +120,16 @@ internal class WalletBackupModel @Inject constructor(
)
},
googleDriveOption = LabelUM(
text = resourceReference(R.string.common_coming_soon),
style = LabelStyle.REGULAR,
text = resourceReference(R.string.hw_backup_no_backup),
style = LabelStyle.WARNING,
),
backedUp = userWallet.backedUp,
isBackedUp = userWallet.backedUp,
googleDriveStatus = BackupStatus.NoBackup,
)
private fun onRecoveryPhraseClick() {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonRecoveryPhrase())
if (uiState.value.backedUp) {
if (uiState.value.isBackedUp) {
getUserWalletUseCase.invoke(params.userWalletId)
.fold(
ifLeft = {
@ -150,6 +155,20 @@ internal class WalletBackupModel @Inject constructor(
}
}
private fun onGoogleDriveBackupClick() {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.ButtonGoogleDriveBackup())
uiMessageSender.send(
DialogMessage(
title = resourceReference(id = R.string.hw_backup_google_drive_dialog_title),
message = resourceReference(id = R.string.hw_backup_google_drive_dialog_message),
firstAction = EventMessageAction(
title = resourceReference(id = R.string.common_ok),
onClick = {},
),
),
)
}
private fun showSeedPhrase(hotWallet: UserWallet.Hot) {
modelScope.launch {
unlockHotWalletContextualUseCase.invoke(hotWallet.hotWalletId)

View file

@ -132,7 +132,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
isBackedUp = false,
),
WalletBackupUM(
hardwareWalletOption = LabelUM(
@ -152,7 +152,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
isBackedUp = false,
),
WalletBackupUM(
hardwareWalletOption = LabelUM(
@ -172,7 +172,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
isBackedUp = false,
),
WalletBackupUM(
hardwareWalletOption = null,
@ -189,7 +189,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider<Wallet
onRecoveryPhraseClick = {},
onGoogleDriveClick = {},
onHardwareWalletClick = {},
backedUp = false,
isBackedUp = false,
),
),
)

View file

@ -0,0 +1,278 @@
package com.tangem.features.hotwallet.walletbackup.model
import arrow.core.left
import arrow.core.right
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase
import com.tangem.features.hotwallet.WalletBackupComponent
import com.tangem.features.hotwallet.walletbackup.entity.BackupStatus
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.hot.sdk.model.UnlockHotWallet
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class WalletBackupModelTest {
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
private val unlockHotWalletContextualUseCase: UnlockHotWalletContextualUseCase = mockk()
private val router: Router = mockk(relaxUnitFun = true)
private val trackingContextProxy: TrackingContextProxy = mockk(relaxUnitFun = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true)
private val paramsContainer: ParamsContainer = mockk()
private val walletId = UserWalletId("011")
private val hotWalletId: HotWalletId = mockk()
private val params = WalletBackupComponent.Params(
userWalletId = walletId,
isColdWalletOptionShown = true,
)
private val hotWalletNotBackedUp: UserWallet.Hot = mockk {
every { walletId } returns this@WalletBackupModelTest.walletId
every { hotWalletId } returns this@WalletBackupModelTest.hotWalletId
every { backedUp } returns false
}
private val hotWalletBackedUp: UserWallet.Hot = mockk {
every { walletId } returns this@WalletBackupModelTest.walletId
every { hotWalletId } returns this@WalletBackupModelTest.hotWalletId
every { backedUp } returns true
}
private val coldWallet: UserWallet.Cold = mockk {
every { walletId } returns this@WalletBackupModelTest.walletId
}
@BeforeEach
fun setUp() {
every { paramsContainer.require<WalletBackupComponent.Params>() } returns params
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletNotBackedUp.right())
}
@Test
fun `GIVEN hot wallet WHEN model is created THEN context added AND BackupScreenOpened sent AND state updated`() =
runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletNotBackedUp.right())
val model = createModel(this)
advanceUntilIdle()
verify { trackingContextProxy.addHotWalletContext() }
verify {
analyticsEventHandler.send(WalletSettingsAnalyticEvents.BackupScreenOpened(isBackedUp = false))
}
val state = model.uiState.value
Assertions.assertEquals(false, state.isBackedUp)
Assertions.assertEquals(BackupStatus.NoBackup, state.googleDriveStatus)
}
@Test
fun `GIVEN cold wallet WHEN model is created THEN context added AND BackupScreenOpened not sent AND state untouched`() =
runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(coldWallet.right())
val model = createModel(this)
advanceUntilIdle()
verify { trackingContextProxy.addHotWalletContext() }
verify(exactly = 0) {
analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.BackupScreenOpened> { true })
}
val state = model.uiState.value
Assertions.assertEquals(false, state.isBackedUp)
Assertions.assertEquals(BackupStatus.ComingSoon, state.googleDriveStatus)
}
@Test
fun `GIVEN error WHEN model is created THEN context added AND BackupScreenOpened not sent`() = runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(GetUserWalletError.UserWalletNotFound.left())
createModel(this)
advanceUntilIdle()
verify { trackingContextProxy.addHotWalletContext() }
verify(exactly = 0) {
analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.BackupScreenOpened> { true })
}
}
@Test
fun `WHEN onDestroy THEN trackingContextProxy removeContext is called`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.onDestroy()
verify { trackingContextProxy.removeContext() }
}
@Test
fun `GIVEN backed up hot wallet AND unlock success WHEN onRecoveryPhraseClick THEN ViewPhrase pushed`() = runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right())
every { getUserWalletUseCase.invoke(walletId) } returns hotWalletBackedUp.right()
coEvery { unlockHotWalletContextualUseCase.invoke(hotWalletId) } returns mockk<UnlockHotWallet>().right()
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onRecoveryPhraseClick()
advanceUntilIdle()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { true }) }
coVerify { unlockHotWalletContextualUseCase.invoke(hotWalletId) }
verify { router.push(route = AppRoute.ViewPhrase(userWalletId = walletId), onComplete = any()) }
}
@Test
fun `GIVEN backed up hot wallet AND unlock failure WHEN onRecoveryPhraseClick THEN ViewPhrase not pushed`() =
runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right())
every { getUserWalletUseCase.invoke(walletId) } returns hotWalletBackedUp.right()
coEvery { unlockHotWalletContextualUseCase.invoke(hotWalletId) } returns Throwable("error").left()
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onRecoveryPhraseClick()
advanceUntilIdle()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { true }) }
coVerify { unlockHotWalletContextualUseCase.invoke(hotWalletId) }
verify(exactly = 0) {
router.push(route = AppRoute.ViewPhrase(userWalletId = walletId), onComplete = any())
}
}
@Test
fun `GIVEN backed up cold wallet WHEN onRecoveryPhraseClick THEN no navigation AND no unlock`() = runTest {
every { getUserWalletUseCase.invokeFlow(walletId) } returns flowOf(hotWalletBackedUp.right())
every { getUserWalletUseCase.invoke(walletId) } returns coldWallet.right()
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onRecoveryPhraseClick()
advanceUntilIdle()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { true }) }
coVerify(exactly = 0) { unlockHotWalletContextualUseCase.invoke(any()) }
verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
}
@Test
fun `GIVEN not backed up wallet WHEN onRecoveryPhraseClick THEN WalletActivation pushed`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onRecoveryPhraseClick()
advanceUntilIdle()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonRecoveryPhrase> { true }) }
verify(exactly = 0) { getUserWalletUseCase.invoke(walletId) }
coVerify(exactly = 0) { unlockHotWalletContextualUseCase.invoke(any()) }
verify {
router.push(
route = AppRoute.WalletActivation(userWalletId = walletId, isBackupExists = false),
onComplete = any(),
)
}
}
@Test
fun `WHEN onHardwareWalletClick THEN ButtonHardwareUpdate sent AND WalletHardwareBackup pushed`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onHardwareWalletClick()
verify { analyticsEventHandler.send(match<WalletSettingsAnalyticEvents.ButtonHardwareUpdate> { true }) }
verify {
router.push(
route = AppRoute.WalletHardwareBackup(userWalletId = walletId),
onComplete = any(),
)
}
}
@Test
fun `WHEN onGoogleDriveClick THEN DialogMessage sent AND analytics sent`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onGoogleDriveClick()
verify {
analyticsEventHandler.send(
event = match<WalletSettingsAnalyticEvents.ButtonGoogleDriveBackup> { true }
)
}
verify {
uiMessageSender.send(
match<DialogMessage> {
val isTitleCorrect = it.title == resourceReference(
id = R.string.hw_backup_google_drive_dialog_title
)
val isMessageCorrect = it.message == resourceReference(
id = R.string.hw_backup_google_drive_dialog_message
)
isTitleCorrect && isMessageCorrect
}
)
}
}
@Test
fun `WHEN onBackClick THEN router pop is called`() = runTest {
val model = createModel(this)
advanceUntilIdle()
model.uiState.value.onBackClick()
verify { router.pop(onComplete = any()) }
}
private fun createModel(testScope: TestScope): WalletBackupModel {
return WalletBackupModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
getUserWalletUseCase = getUserWalletUseCase,
unlockHotWalletContextualUseCase = unlockHotWalletContextualUseCase,
router = router,
trackingContextProxy = trackingContextProxy,
analyticsEventHandler = analyticsEventHandler,
uiMessageSender = uiMessageSender,
)
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}

View file

@ -2,9 +2,6 @@ package com.tangem.features.onboarding.v2.common.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import kotlin.collections.putAll
sealed class OnboardingEvent(
category: String,
@ -12,41 +9,6 @@ sealed class OnboardingEvent(
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category, event, params) {
class Started : OnboardingEvent("Onboarding", "Onboarding Started")
class Finished : OnboardingEvent("Onboarding", "Onboarding Finished")
sealed class CreateWallet(
event: String,
params: Map<String, String> = mapOf(),
) : OnboardingEvent("Onboarding / Create Wallet", event, params) {
class ScreenOpened : CreateWallet("Create Wallet Screen Opened")
class ButtonCreateWallet : CreateWallet("Button - Create Wallet")
class ButtonOtherOptions : CreateWallet("Button - Other Options")
class WalletCreatedSuccessfully(
creationType: WalletCreationType = WalletCreationType.PrivateKey,
seedPhraseLength: Int? = null,
passPhraseState: AnalyticsParam.EmptyFull,
referralId: String?,
) : CreateWallet(
event = "Wallet Created Successfully",
params = buildMap {
put("Creation Type", creationType.value)
put("Passphrase", passPhraseState.value)
if (seedPhraseLength != null) {
put("Seed Phrase Length", seedPhraseLength.toString())
}
putAll(getReferralParams(referralId))
},
), AppsFlyerIncludedEvent
sealed class WalletCreationType(val value: String) {
data object PrivateKey : WalletCreationType(value = "Private Key")
data object NewSeed : WalletCreationType(value = "New Seed")
data object SeedImport : WalletCreationType(value = "Seed Import")
}
}
sealed class Backup(
event: String,
params: Map<String, String> = mapOf(),

View file

@ -19,6 +19,7 @@ import com.arkivanov.decompose.router.stack.*
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.instancekeeper.getOrCreateSimple
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
@ -28,7 +29,6 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent
import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.accesscode.MultiWalletAccessCodeComponent
@ -229,7 +229,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor
}
Done -> {
// final step - navigate to parent
analyticsHandler.send(OnboardingEvent.Finished())
analyticsHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished())
val userWallet = childParams.multiWalletState.value.resultUserWallet ?: return
params.onDone(userWallet)
}

View file

@ -6,6 +6,7 @@ import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -19,7 +20,6 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.state.MultiWalletCreateWalletUM
@ -67,12 +67,12 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
resourceReference(R.string.onboarding_create_wallet_body)
},
onCreateWalletClick = {
analyticsHandler.send(OnboardingEvent.CreateWallet.ButtonCreateWallet())
analyticsHandler.send(OnboardingAnalyticsEvent.CreateWallet.ButtonCreateWallet())
createWallet(false)
},
showOtherOptionsButton = params.parentParams.withSeedPhraseFlow,
onOtherOptionsClick = {
analyticsHandler.send(OnboardingEvent.CreateWallet.ButtonOtherOptions())
analyticsHandler.send(OnboardingAnalyticsEvent.CreateWallet.ButtonOtherOptions())
modelScope.launch {
onDone.emit(Step.SeedPhrase)
}
@ -85,7 +85,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
val onDone = MutableSharedFlow<Step>()
init {
analyticsHandler.send(OnboardingEvent.CreateWallet.ScreenOpened())
analyticsHandler.send(OnboardingAnalyticsEvent.CreateWallet.ScreenOpened())
}
private fun createWallet(shouldReset: Boolean) {
@ -110,7 +110,7 @@ internal class MultiWalletCreateWalletModel @Inject constructor(
cardRepository.startCardActivation(cardId = result.data.card.cardId)
analyticsHandler.send(
event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,
),

View file

@ -29,7 +29,6 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.IsWalletAlreadySavedUseCase
import com.tangem.features.hotwallet.MnemonicRepository
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.ui.OnboardingDialogUM
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model.builder.GenerateSeedPhraseUiStateBuilder
@ -239,11 +238,11 @@ internal class MultiWalletSeedPhraseModel @Inject constructor(
if (!isWalletAlreadySaved) {
analyticsHandler.send(
OnboardingEvent.CreateWallet.WalletCreatedSuccessfully(
OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
creationType = if (generatedSeedPhrase) {
OnboardingEvent.CreateWallet.WalletCreationType.NewSeed
AnalyticsParam.WalletCreationType.NewSeed
} else {
OnboardingEvent.CreateWallet.WalletCreationType.SeedImport
AnalyticsParam.WalletCreationType.SeedImport
},
seedPhraseLength = mnemonic.mnemonicComponents.size,
passPhraseState = if (passphrase.isNullOrBlank()) {

View file

@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.multiwallet.impl.model
import com.tangem.common.routing.AppRoute
import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -12,7 +13,6 @@ import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.ui.interruptBackupDialog
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
@ -60,7 +60,7 @@ internal class OnboardingMultiWalletModel @Inject constructor(
val uiState = _uiState.asStateFlow()
init {
analyticsHandler.send(OnboardingEvent.Started())
analyticsHandler.send(OnboardingAnalyticsEvent.Onboarding.Started())
initScreenTitle()
loadCardArtwork()
subscribeToBackups()

View file

@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.note.impl.child.create.model
import com.tangem.common.CompletionResult
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -10,10 +11,9 @@ import com.tangem.core.ui.components.artwork.ArtworkUM
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
import com.tangem.domain.wallets.usecase.SaveWalletUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.note.impl.child.create.OnboardingNoteCreateWalletComponent
import com.tangem.features.onboarding.v2.note.impl.child.create.ui.state.OnboardingNoteCreateWalletUM
import com.tangem.sdk.api.TangemSdkManager
@ -46,11 +46,11 @@ internal class OnboardingNoteCreateWalletModel @Inject constructor(
)
init {
analyticsEventHandler.send(OnboardingEvent.CreateWallet.ScreenOpened())
analyticsEventHandler.send(OnboardingAnalyticsEvent.CreateWallet.ScreenOpened())
modelScope.launch {
val scanResponse = params.childParams.commonState.value.scanResponse ?: return@launch
if (!cardRepository.isActivationStarted(scanResponse.card.cardId)) {
analyticsEventHandler.send(OnboardingEvent.Started())
analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Started())
}
}
observeArtwork()
@ -64,11 +64,10 @@ internal class OnboardingNoteCreateWalletModel @Inject constructor(
it.copy(createWalletInProgress = true)
}
val scanResponse = params.childParams.commonState.value.scanResponse ?: return@launch
val result = tangemSdkManager.createProductWallet(scanResponse)
when (result) {
when (val result = tangemSdkManager.createProductWallet(scanResponse)) {
is CompletionResult.Success -> {
analyticsEventHandler.send(
event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,
),

View file

@ -3,6 +3,7 @@ package com.tangem.features.onboarding.v2.note.impl.model
import com.arkivanov.decompose.router.stack.StackNavigation
import com.tangem.common.ui.userwallet.converter.ArtworkUMConverter
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -10,7 +11,6 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.features.onboarding.v2.common.ui.exitOnboardingDialog
import com.tangem.features.onboarding.v2.note.api.OnboardingNoteComponent
import com.tangem.features.onboarding.v2.note.impl.OnboardingNoteInnerNavigationState
@ -76,7 +76,7 @@ internal class OnboardingNoteModel @Inject constructor(
}
fun onWalletCreated(userWallet: UserWallet) {
analyticsEventHandler.send(OnboardingEvent.Finished())
analyticsEventHandler.send(OnboardingAnalyticsEvent.Onboarding.Finished())
commonUiState.update {
it.copy(userWallet = userWallet)
}

View file

@ -10,6 +10,7 @@ import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
@ -175,7 +176,7 @@ internal class OnboardingTwinModel @Inject constructor(
}
analyticsEventHandler.send(
event = OnboardingEvent.CreateWallet.WalletCreatedSuccessfully(
event = OnboardingAnalyticsEvent.CreateWallet.WalletCreatedSuccessfully(
passPhraseState = AnalyticsParam.EmptyFull.Empty,
referralId = appsFlyerStore.get()?.refcode,
),

View file

@ -19,7 +19,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.wallets.usecase.GetCardImageUseCase
import com.tangem.features.onboarding.v2.TitleProvider
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
import com.tangem.core.analytics.models.event.OnboardingAnalyticsEvent
import com.tangem.features.onboarding.v2.impl.R
import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent
import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams
@ -114,7 +114,7 @@ internal class OnboardingMultiWalletModelTest {
createModel(this)
advanceUntilIdle()
verify { analyticsHandler.send(match<OnboardingEvent.Started> { true }) }
verify { analyticsHandler.send(match<OnboardingAnalyticsEvent.Onboarding.Started> { true }) }
}
@Test

View file

@ -13,7 +13,7 @@ internal sealed class StoriesEvents(
val source: String,
val watchCount: String,
) : StoriesEvents(
event = "Swap Stories",
event = "Swap Story",
params = mapOf(
AnalyticsParam.SOURCE to source,
WATCHED to watchCount,

View file

@ -1,3 +1,5 @@
package com.tangem.features.swap
interface SwapFeatureToggles
interface SwapFeatureToggles {
val isSwapSwitchToTransferEnabled: Boolean
}

View file

@ -9,6 +9,14 @@ plugins {
android {
namespace = "com.tangem.features.domain.swap"
testOptions {
unitTests.isIncludeAndroidResources = false
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
@ -62,4 +70,8 @@ dependencies {
implementation(tangemDeps.card.core)
implementation(deps.moshi)
ksp(deps.moshi.kotlin.codegen)
/** Test */
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -0,0 +1,913 @@
package com.tangem.feature.swap.domain
import android.util.Base64
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.transaction.models.AllowanceInfo
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.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Tests for [SwapInteractorImpl.findBestQuote] the core quote-dispatch method.
*
* Covers:
* - Empty / unparseable amount handling
* - DEX provider path on EVM networks (balance enough, allowance enough)
* - DEX provider repository error handling (returns SwapError)
* - DEX_BRIDGE provider sharing the DEX dispatch branch
* - Solana DEX path routing via the Solana-specific branch
* - CEX provider dispatch including null txFee edge case
* - yieldSupplyStatus.isActive returning [ExpressDataError.DexActiveSupplyError]
* - Mixed (DEX + CEX) provider list each routed to its own path
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val solanaNetwork = Blockchain.Solana.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
@BeforeEach
fun setup() {
// Common stubs that most tests rely on. Individual tests can override.
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
coEvery {
getCurrencyCheckUseCase.invoke(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns buildCryptoCurrencyCheck()
coEvery {
validateTransactionUseCase.invoke(
amount = any(),
fee = any(),
memo = any(),
destination = any(),
userWalletId = any(),
network = any(),
)
} returns Unit.right()
coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers {
firstArg<Set<CryptoCurrency.RawID>>().map { rawId ->
QuoteStatus(
rawCurrencyId = rawId,
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatRate = BigDecimal.ONE,
fiatRateUSD = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
),
)
}.toSet()
}
coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right()
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false
coEvery {
getAllowanceInfoUseCase.invoke(
userWalletId = any(),
cryptoCurrency = any(),
spenderAddress = any(),
requiredAmount = any(),
)
} returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right()
every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns
mockk<TransactionExtras>(relaxed = true).right()
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns mockk<TransactionFee.Single>(relaxed = true).right()
}
@Nested
inner class EmptyAmountHandling {
@Test
fun `should return EmptyAmountState for all providers when amount is zero`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider, cexProvider),
amountToSwap = "0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(2)
assertThat(result[dexProvider]).isInstanceOf(SwapState.EmptyAmountState::class.java)
assertThat(result[cexProvider]).isInstanceOf(SwapState.EmptyAmountState::class.java)
}
@Test
fun `should return EmptyAmountState for all providers when amount is unparseable`() = runTest {
// Given
val provider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(provider),
amountToSwap = "not-a-number",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(1)
assertThat(result[provider]).isInstanceOf(SwapState.EmptyAmountState::class.java)
}
@Test
fun `should return empty map when providers list is empty`() = runTest {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = emptyList(),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).isEmpty()
}
}
@Nested
inner class DexProviderPath {
@Test
fun `should return SwapState for DEX provider when repository findBestQuote succeeds and balance enough`() =
runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0",
isCoin = true,
amount = BigDecimal("10"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5"))
val swapData = buildSwapDataModelDex()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns swapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — has a result entry for the DEX provider; type of state is decided by internal logic
assertThat(result).hasSize(1)
assertThat(result.containsKey(dexProvider)).isTrue()
assertThat(result[dexProvider]).isNotNull()
}
@Test
fun `should return SwapError with DexActiveSupplyError when yieldSupply is active`() = runTest {
// Given — yieldSupplyActive=true short-circuits to DexActiveSupplyError
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
yieldSupplyActive = true,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.SwapError::class.java)
val swapError = (state ?: error("state must not be null")) as SwapState.SwapError
assertThat(swapError.error).isEqualTo(ExpressDataError.DexActiveSupplyError)
}
@Test
fun `should set isBalanceEnough to false when from-token balance is less than swap amount`() = runTest {
// Given — balance is 0.01, swap amount is 1.0 → insufficient
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0",
isCoin = true,
amount = BigDecimal("0.01"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5"))
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.isBalanceEnough).isFalse()
}
@Test
fun `should return non-null state for DEX provider when repository findBestQuote returns error`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0",
isCoin = true,
amount = BigDecimal("10"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns ExpressDataError.UnknownError.left()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — a SwapState is emitted for the provider (not an EmptyAmountState)
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isNotNull()
assertThat(state).isNotInstanceOf(SwapState.EmptyAmountState::class.java)
}
}
@Nested
inner class DexBridgeProviderPath {
@Test
fun `should return entry keyed by the DEX_BRIDGE provider type`() = runTest {
// Given — DEX_BRIDGE shares the same DEX branch as DEX
val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val swapData = buildSwapDataModelDex()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexBridgeProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexBridgeProvider.providerId,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns swapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexBridgeProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(1)
assertThat(result.keys.first().type).isEqualTo(ExchangeProviderType.DEX_BRIDGE)
}
}
@Nested
inner class SolanaDexPath {
@Test
fun `should produce result entry when network is Solana and quote is successful`() = runTest {
// Given
mockkStatic(Base64::class)
every { Base64.decode(any<String>(), any()) } returns ByteArray(0)
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = solanaNetwork,
isCoin = true,
amount = BigDecimal("10"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
val quoteModel = buildQuoteModel()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = solanaNetwork,
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
val solanaSwapData = buildSwapDataModelDex()
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns solanaSwapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(1)
assertThat(result.containsKey(dexProvider)).isTrue()
}
@Test
fun `should return SwapError TooLargeSolanaTransactionError when tx bytes exceed threshold on Cold wallet`() =
runTest {
// Given — decode returns an oversized array; mock the Solana helper to preserve its size
mockkStatic(Base64::class)
every { Base64.decode(any<String>(), any()) } returns ByteArray(931)
io.mockk.mockkObject(SolanaTransactionHelper)
every {
SolanaTransactionHelper.removeSignaturesPlaceholders(any())
} returns ByteArray(931)
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = solanaNetwork,
isCoin = true,
amount = BigDecimal("10"),
).let { status ->
// replace the relaxed UserWallet mock with a real Cold mock so `is UserWallet.Cold` is true
SwapCurrencyStatus(
userWallet = coldWallet,
status = status.status,
account = status.account,
)
}
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
val quoteModel = buildQuoteModel()
val solanaSwapData = buildSwapDataModelDex(txData = "oversized==")
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = solanaNetwork,
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns solanaSwapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — oversized Solana tx on Cold wallet produces SwapError with TooLargeSolanaTransactionError
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.SwapError::class.java)
val swapError = state as SwapState.SwapError
assertThat(swapError.error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError)
}
@Test
fun `should produce non-empty state via Solana path when balance insufficient`() = runTest {
// Given — Solana path with Right quote but insufficient balance → getQuotesState branch
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = solanaNetwork,
isCoin = true,
amount = BigDecimal("0.000001"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
val quoteModel = buildQuoteModel()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = solanaNetwork,
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1000.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(1)
assertThat(result[dexProvider]).isNotNull()
}
}
@Nested
inner class CexProviderPath {
@Test
fun `should produce result entry for CEX provider`() = runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
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,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(1)
assertThat(result.containsKey(cexProvider)).isTrue()
assertThat(result[cexProvider]).isNotNull()
}
@Test
fun `should produce result entry for CEX provider with minimal fee state`() = runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("5"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
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,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
assertThat(result).hasSize(1)
assertThat(result[cexProvider]).isNotNull()
}
}
@Nested
inner class MixedProviderDispatch {
@Test
fun `should dispatch each provider to its branch and return one entry per provider`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-1")
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, "cex-1")
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val swapData = buildSwapDataModelDex()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = "dex-1",
rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = "cex-1",
rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = "dex-1",
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns swapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider, cexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — both providers have an entry
assertThat(result).hasSize(2)
assertThat(result.containsKey(dexProvider)).isTrue()
assertThat(result.containsKey(cexProvider)).isTrue()
}
@Test
fun `should return one entry per provider for DEX plus CEX plus DEX_BRIDGE on non-Solana network`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-mix")
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, "cex-mix")
val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, "dex-bridge-mix")
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
amount = BigDecimal("10"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val swapData = buildSwapDataModelDex()
listOf("dex-mix", "cex-mix", "dex-bridge-mix").forEach { pid ->
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = pid,
rateType = any(),
)
} returns quoteModel.right()
}
listOf("dex-mix", "dex-bridge-mix").forEach { pid ->
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = pid,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns swapData.right()
}
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider, cexProvider, dexBridgeProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — all three providers are dispatched and each has an entry
assertThat(result).hasSize(3)
assertThat(result.containsKey(dexProvider)).isTrue()
assertThat(result.containsKey(cexProvider)).isTrue()
assertThat(result.containsKey(dexBridgeProvider)).isTrue()
assertThat(result[dexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
assertThat(result[dexBridgeProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
}
}
}
// region — test-local helpers
private fun buildCryptoCurrencyCheck(): CryptoCurrencyCheck = CryptoCurrencyCheck(
dustValue = null,
reserveAmount = null,
minimumSendAmount = null,
existentialDeposit = null,
utxoAmountLimit = null,
isAccountFunded = true,
rentWarning = null,
isMemoRequired = false,
)
private fun buildSwapDataModelDex(
txData: String = "dGVzdA==",
txValue: String? = "0",
toAmount: BigDecimal = BigDecimal("0.5"),
): SwapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(toAmount, 18),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal.ONE, 18),
toAmount = SwapAmount(toAmount, 18),
txValue = txValue,
txId = "tx-id-123",
txTo = "0xRecipient",
txExtraId = null,
txFrom = "0xSender",
txData = txData,
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
),
)
// endregion

View file

@ -0,0 +1,160 @@
package com.tangem.feature.swap.domain
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import io.mockk.coEvery
import io.mockk.every
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for [SwapInteractorImpl.findProvidersForPair] and [SwapInteractorImpl.findProvidersForPairWithCheck].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplFindProvidersForPairTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
@Nested
inner class FindProvidersForPair {
@Test
fun `should return providers of the first pair whose to-contractAddress equals destination contract`() {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val expectedProvider = buildSwapProvider(ExchangeProviderType.DEX, "expected")
val matchingPair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = listOf(expectedProvider),
)
// When
val result = sut.findProvidersForPair(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
pairs = listOf(matchingPair),
)
// Then
assertThat(result).containsExactly(expectedProvider)
}
@Test
fun `should return empty list when pairs list is empty`() {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.findProvidersForPair(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
pairs = emptyList(),
)
// Then
assertThat(result).isEmpty()
}
@Test
fun `should return empty list when no pair's to-contractAddress equals destination contract`() {
// Given — destination is a token with contractAddress "0xAbc", but pair's to-contract is "0"
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(
networkRawId = btcNetwork,
contractAddress = "0xAbc",
isCoin = false,
)
val unrelatedPair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = listOf(buildSwapProvider(ExchangeProviderType.CEX, "unrelated")),
)
// When
val result = sut.findProvidersForPair(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
pairs = listOf(unrelatedPair),
)
// Then
assertThat(result).isEmpty()
}
}
@Nested
inner class FindProvidersForPairWithCheck {
@Test
fun `should return empty list when rampStateManager checkAssetRequirements returns false`() = runTest {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
)
coEvery {
getAssetRequirementsUseCase.invoke(any(), any())
} returns null.right()
every { rampStateManager.checkAssetRequirements(any()) } returns false
// When
val result = sut.findProvidersForPairWithCheck(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
pairs = listOf(pair),
)
// Then
assertThat(result).isEmpty()
}
@Test
fun `should return providers from matching pair when checkAssetRequirements returns true`() = runTest {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true)
val providerA = buildSwapProvider(ExchangeProviderType.DEX, "A")
val providerB = buildSwapProvider(ExchangeProviderType.CEX, "B")
val pair = buildSwapPairLeast(
fromNetwork = ethNetwork,
fromContract = "0",
toNetwork = btcNetwork,
toContract = "0",
providers = listOf(providerA, providerB),
)
coEvery {
getAssetRequirementsUseCase.invoke(any(), any())
} returns null.right()
every { rampStateManager.checkAssetRequirements(any()) } returns true
// When
val result = sut.findProvidersForPairWithCheck(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
pairs = listOf(pair),
)
// Then
assertThat(result).containsExactly(providerA, providerB)
}
}
}

View file

@ -0,0 +1,86 @@
package com.tangem.feature.swap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for [SwapInteractorImpl.getNativeToken].
*
* Behavior:
* - Look up cached portfolio coins for the user wallet via [MultiWalletCryptoCurrenciesSupplier].
* - Return the coin matching the target network (by `id` and `derivationPath`).
* - If supplier returns null or no match fall back to [CurrenciesRepository.createCoinCurrency].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplGetNativeTokenTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
@Test
fun `should return a Coin from the supplier whose network matches the target`() = runTest {
// Given — a single matching coin in the supplier
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val targetNetwork = fromStatus.currency.network
val matchingCoin = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { network } returns targetNetwork
}
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(matchingCoin)
// When
val result = sut.getNativeToken(fromStatus)
// Then
assertThat(result).isSameInstanceAs(matchingCoin)
}
@Test
fun `should fall back to createCoinCurrency when supplier returns null`() = runTest {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val createdCoin = buildCoinCurrency(networkRawId = ethNetwork)
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null
coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin
// When
val result = sut.getNativeToken(fromStatus)
// Then
assertThat(result).isSameInstanceAs(createdCoin)
coVerify(exactly = 1) { currenciesRepository.createCoinCurrency(any()) }
}
@Test
fun `should fall back to createCoinCurrency when no matching coin is in the supplier's list`() = runTest {
// Given — all returned coins are for a different network
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val unrelatedCoin = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { network } returns mockk<Network>(relaxed = true) {
every { id } returns mockk(relaxed = true)
every { derivationPath } returns Network.DerivationPath.None
}
}
val createdCoin = buildCoinCurrency(networkRawId = ethNetwork)
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(unrelatedCoin)
coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin
// When
val result = sut.getNativeToken(fromStatus)
// Then
assertThat(result).isSameInstanceAs(createdCoin)
}
}

View file

@ -0,0 +1,201 @@
package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.swap.models.SwapTxType
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import io.mockk.coEvery
import io.mockk.coVerify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplGetPairTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
private val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0",
isCoin = true,
)
private val toStatus = buildSwapCurrencyStatus(
networkRawId = btcNetwork,
contractAddress = "0",
isCoin = true,
)
@Nested
inner class `getPair happy path` {
@Test
fun `should return Right with mapped SwapPairLeast list when use case succeeds`() = runTest {
// Given
val expressProvider = buildExpressProvider(providerId = "p1", type = ExpressProviderType.DEX)
val pairModel = buildSwapPairModel(
fromNetworkRawId = ethNetwork,
fromContractAddress = "0",
toNetworkRawId = btcNetwork,
toContractAddress = "0",
providers = listOf(expressProvider),
)
coEvery {
getSwapPairUseCase.invoke(
primarySwapCurrencyStatus = fromStatus,
secondarySwapCurrencyStatus = toStatus,
filterProviderTypes = any(),
swapTxType = SwapTxType.Swap,
)
} returns listOf(pairModel).right()
// When
val result = sut.getPair(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
filterProviderTypes = listOf(ExchangeProviderType.DEX),
)
// Then
assertThat(result.isRight()).isTrue()
result.onRight { pairs ->
assertThat(pairs).hasSize(1)
val pair = pairs.first()
assertThat(pair.from.network).isEqualTo(ethNetwork)
assertThat(pair.from.contractAddress).isEqualTo("0")
assertThat(pair.to.network).isEqualTo(btcNetwork)
assertThat(pair.providers).hasSize(1)
assertThat(pair.providers.first().providerId).isEqualTo("p1")
}
}
@Test
fun `should map coin contractAddress to 0 in LeastTokenInfo`() = runTest {
// Given — coin currency (contractAddress = "0" by convention)
val pairModel = buildSwapPairModel(
fromNetworkRawId = ethNetwork,
fromContractAddress = "0",
toNetworkRawId = btcNetwork,
toContractAddress = "0",
)
coEvery {
getSwapPairUseCase.invoke(
primarySwapCurrencyStatus = any(),
secondarySwapCurrencyStatus = any(),
filterProviderTypes = any(),
swapTxType = any(),
)
} returns listOf(pairModel).right()
// When
val result = sut.getPair(fromStatus, toStatus, emptyList())
// Then
assertThat(result.isRight()).isTrue()
result.onRight { pairs ->
assertThat(pairs.first().from.contractAddress).isEqualTo("0")
assertThat(pairs.first().to.contractAddress).isEqualTo("0")
}
}
@Test
fun `should map all ExchangeProviderType variants to ExpressProviderType correctly`() = runTest {
// Given
coEvery {
getSwapPairUseCase.invoke(
primarySwapCurrencyStatus = any(),
secondarySwapCurrencyStatus = any(),
filterProviderTypes = listOf(
ExpressProviderType.DEX,
ExpressProviderType.CEX,
ExpressProviderType.DEX_BRIDGE,
),
swapTxType = SwapTxType.Swap,
)
} returns emptyList<com.tangem.domain.swap.models.SwapPairModel>().right()
// When
val result = sut.getPair(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
filterProviderTypes = listOf(
ExchangeProviderType.DEX,
ExchangeProviderType.CEX,
ExchangeProviderType.DEX_BRIDGE,
),
)
// Then
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) {
getSwapPairUseCase.invoke(
primarySwapCurrencyStatus = any(),
secondarySwapCurrencyStatus = any(),
filterProviderTypes = listOf(
ExpressProviderType.DEX,
ExpressProviderType.CEX,
ExpressProviderType.DEX_BRIDGE,
),
swapTxType = SwapTxType.Swap,
)
}
}
@Test
fun `should return empty list when use case returns empty pairs`() = runTest {
// Given
coEvery {
getSwapPairUseCase.invoke(
primarySwapCurrencyStatus = any(),
secondarySwapCurrencyStatus = any(),
filterProviderTypes = any(),
swapTxType = any(),
)
} returns emptyList<com.tangem.domain.swap.models.SwapPairModel>().right()
// When
val result = sut.getPair(fromStatus, toStatus, emptyList())
// Then
assertThat(result.isRight()).isTrue()
result.onRight { pairs ->
assertThat(pairs).isEmpty()
}
}
}
@Nested
inner class `getPair error path` {
@Test
fun `should return Left with ExpressError when use case returns Left`() = runTest {
// Given
val expectedError = ExpressError.DataError(code = 400, description = "bad request")
coEvery {
getSwapPairUseCase.invoke(
primarySwapCurrencyStatus = any(),
secondarySwapCurrencyStatus = any(),
filterProviderTypes = any(),
swapTxType = any(),
)
} returns expectedError.left()
// When
val result = sut.getPair(fromStatus, toStatus, emptyList())
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(ExpressError.DataError::class.java)
assertThat((error as ExpressError.DataError).code).isEqualTo(400)
}
}
}
}

View file

@ -0,0 +1,76 @@
package com.tangem.feature.swap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Tests for [SwapInteractorImpl.getTokenBalance].
*
* Trivial conversion: `SwapAmount(value.amount ?: ZERO, currency.decimals)`.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplGetTokenBalanceTest : SwapInteractorImplTestBase() {
@Test
fun `should return SwapAmount with the reported balance and decimals when value amount is non-null`() {
// Given
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { decimals } returns 18
}
val value = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
every { amount } returns BigDecimal("5.75")
}
val status = CryptoCurrencyStatus(currency = currency, value = value)
// When
val result = sut.getTokenBalance(status)
// Then
assertThat(result.value).isEqualTo(BigDecimal("5.75"))
assertThat(result.decimals).isEqualTo(18)
}
@Test
fun `should return SwapAmount with ZERO when value amount is null`() {
// Given — a non-Loaded value with null amount (e.g. Loading state)
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { decimals } returns 8
}
val value = mockk<CryptoCurrencyStatus.Loading>(relaxed = true) {
every { amount } returns null
}
val status = CryptoCurrencyStatus(currency = currency, value = value)
// When
val result = sut.getTokenBalance(status)
// Then
assertThat(result.value).isEqualTo(BigDecimal.ZERO)
assertThat(result.decimals).isEqualTo(8)
}
@Test
fun `should preserve decimals from the underlying currency`() {
// Given — Token with custom decimals
val currency = mockk<CryptoCurrency.Token>(relaxed = true) {
every { decimals } returns 6
}
val value = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
every { amount } returns BigDecimal("100")
}
val status = CryptoCurrencyStatus(currency = currency, value = value)
// When
val result = sut.getTokenBalance(status)
// Then
assertThat(result.decimals).isEqualTo(6)
assertThat(result.value).isEqualTo(BigDecimal("100"))
}
}

View file

@ -0,0 +1,441 @@
package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Tests for [SwapInteractorImpl.loadFeeForSwapTransaction] (both overloads).
*
* Overload 1 (returns [Either<GetFeeError, TransactionFeeExtended>]):
* - DEX / DEX_BRIDGE always GaslessError.NetworkIsNotSupported
* - CEX + zero or unparseable amount UnknownError
* - CEX + selectedFeeToken != null delegates to [estimateFeeForTokenUseCase]
* - CEX + selectedFeeToken == null delegates to [estimateFeeForGaslessTxUseCase]
*
* Overload 2 (returns [Either<GetFeeError, TransactionFee>]):
* - DEX / DEX_BRIDGE + zero amount UnknownError
* - DEX / DEX_BRIDGE + getExchangeData error UnknownError
* - CEX + zero amount UnknownError
* - CEX + non-zero amount delegates to [estimateFeeUseCase]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplLoadFeeTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
// -------------------------------------------------------------------------
// Overload 1
// -------------------------------------------------------------------------
@Nested
inner class `overload 1 — CEX and token fee paths` {
@Test
fun `should return Left GaslessError for DEX provider`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java)
}
}
@Test
fun `should return Left GaslessError for DEX_BRIDGE provider`() = runTest {
// Given
val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexBridgeProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java)
}
}
@Test
fun `should return Left UnknownError for CEX provider when amount is zero`() = runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `should return Left UnknownError for CEX provider when amount is invalid string`() = runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "not-a-decimal",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `should delegate to estimateFeeForTokenUseCase when CEX provider has non-null selectedFeeToken`() =
runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true)
val expectedFeeExtended = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
estimateFeeForTokenUseCase.invoke(
userWallet = any(),
feeTokenCurrencyStatus = feeTokenStatus,
sendingTokenCurrencyStatus = any(),
amount = any(),
)
} returns expectedFeeExtended.right()
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "1.5",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = feeTokenStatus,
)
// Then
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) {
estimateFeeForTokenUseCase.invoke(
userWallet = any(),
feeTokenCurrencyStatus = feeTokenStatus,
sendingTokenCurrencyStatus = any(),
amount = BigDecimal("1.5"),
)
}
}
@Test
fun `should pass positive non-NaN amount to estimateFeeForGaslessTxUseCase for CEX with tiny nonzero amount and null selectedFeeToken`() =
runTest {
// Given — tiny but nonzero amount; null selectedFeeToken routes to estimateFeeForGaslessTxUseCase
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val feeExtended = mockk<TransactionFeeExtended>(relaxed = true)
val capturedAmount = slot<BigDecimal>()
coEvery {
estimateFeeForGaslessTxUseCase.invoke(
amount = capture(capturedAmount),
userWallet = any(),
sendingTokenCurrencyStatus = any(),
)
} returns feeExtended.right()
// When
sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "0.000001",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = null,
)
// Then — captured amount is positive, finite, non-NaN
assertThat(capturedAmount.captured).isNotNull()
assertThat(capturedAmount.captured.signum()).isGreaterThan(0)
assertThat(capturedAmount.captured.toDouble().isNaN()).isFalse()
assertThat(capturedAmount.captured.toDouble().isInfinite()).isFalse()
// verify estimateFeeForGaslessTxUseCase was called with the exact parsed amount
coVerify(exactly = 1) {
estimateFeeForGaslessTxUseCase.invoke(
amount = BigDecimal("0.000001"),
userWallet = any(),
sendingTokenCurrencyStatus = any(),
)
}
}
@Test
fun `should delegate to estimateFeeForGaslessTxUseCase when CEX provider has null selectedFeeToken`() =
runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val expectedFeeExtended = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
estimateFeeForGaslessTxUseCase.invoke(
amount = any(),
userWallet = any(),
sendingTokenCurrencyStatus = any(),
)
} returns expectedFeeExtended.right()
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "2.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) {
estimateFeeForGaslessTxUseCase.invoke(
amount = BigDecimal("2.0"),
userWallet = any(),
sendingTokenCurrencyStatus = any(),
)
}
}
}
// -------------------------------------------------------------------------
// Overload 2
// -------------------------------------------------------------------------
@Nested
inner class `overload 2 — DEX and CEX TransactionFee paths` {
@Test
fun `should return Left UnknownError for DEX when amount is zero`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `should return Left UnknownError for DEX when getExchangeData returns error`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = any(),
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns ExpressDataError.UnknownError.left()
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `should not call getExchangeData and return UnknownError for DEX when amount is zero`() = runTest {
// Given — zero amount must short-circuit before hitting repository
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) }
coVerify(exactly = 0) {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = any(),
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
}
}
@Test
fun `should return Left UnknownError for DEX_BRIDGE when amount is zero`() = runTest {
// Given
val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexBridgeProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
}
@Test
fun `should return Left UnknownError for CEX when amount is zero`() = runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
}
@Test
fun `should delegate to estimateFeeUseCase for CEX provider with non-zero amount`() = runTest {
// Given — return Left to avoid the patchTransactionFeeForSwap branch which requires concrete Fee types
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
coEvery {
estimateFeeUseCase.invoke(
amount = any(),
userWallet = any(),
cryptoCurrencyStatus = any(),
)
} returns GetFeeError.UnknownError.left()
// When
sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
)
// Then
coVerify(exactly = 1) {
estimateFeeUseCase.invoke(
amount = BigDecimal("1.0"),
userWallet = any(),
cryptoCurrencyStatus = any(),
)
}
}
}
}

View file

@ -0,0 +1,164 @@
package com.tangem.feature.swap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
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.ExchangeStatus
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import io.mockk.clearMocks
import io.mockk.coVerify
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Tests for [SwapInteractorImpl.storeSwapTransaction].
*
* Behavior:
* - Delegates to [SwapTransactionRepository.storeTransaction] with fields derived from
* the from/to currency statuses, the amount, the provider, and the [SwapDataModel].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplStoreSwapTransactionTest : SwapInteractorImplTestBase() {
@BeforeEach
fun resetSwapTransactionRepository() {
clearMocks(swapTransactionRepository)
}
@Test
fun `should delegate to swapTransactionRepository storeTransaction with correct fields`() = runTest {
// Given
val fromStatus = buildSwapCurrencyStatus(
networkRawId = Blockchain.Ethereum.toNetworkId(),
isCoin = true,
)
val toStatus = buildSwapCurrencyStatus(
networkRawId = Blockchain.Bitcoin.toNetworkId(),
isCoin = true,
)
val amount = SwapAmount(value = BigDecimal("1.25"), decimals = 18)
val provider = buildSwapProvider(type = ExchangeProviderType.DEX, providerId = "dex-store")
val swapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.42"), 8),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal("1.25"), 18),
toAmount = SwapAmount(BigDecimal("0.42"), 8),
txValue = "0",
txId = "persisted-tx-id",
txTo = "0xRecipient",
txExtraId = null,
txFrom = "0xSender",
txData = "dGVzdA==",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
),
)
val timestamp = 1_700_000_000L
val transactionSlot = slot<SavedSwapTransactionModel>()
// When
sut.storeSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = amount,
swapProvider = provider,
swapDataModel = swapDataModel,
timestamp = timestamp,
txExternalUrl = "https://explorer/tx",
txExternalId = "ext-id-1",
averageDuration = 120,
)
// Then
coVerify(exactly = 1) {
swapTransactionRepository.storeTransaction(
fromUserWalletId = any(),
toUserWalletId = any(),
fromCryptoCurrency = any(),
toCryptoCurrency = any(),
fromAccount = any(),
toAccount = any(),
transaction = capture(transactionSlot),
)
}
val captured = transactionSlot.captured
assertThat(captured.txId).isEqualTo("persisted-tx-id")
assertThat(captured.provider).isEqualTo(provider)
assertThat(captured.timestamp).isEqualTo(timestamp)
assertThat(captured.fromCryptoAmount).isEqualTo(BigDecimal("1.25"))
assertThat(captured.toCryptoAmount).isEqualTo(BigDecimal("0.42"))
val status = requireNotNull(captured.status) { "status should not be null" }
assertThat(status.providerId).isEqualTo("dex-store")
assertThat(status.status).isEqualTo(ExchangeStatus.New)
assertThat(status.txExternalUrl).isEqualTo("https://explorer/tx")
assertThat(status.txExternalId).isEqualTo("ext-id-1")
assertThat(status.averageDuration).isEqualTo(120)
}
@Test
fun `should accept null txExternalUrl and txExternalId and averageDuration`() = runTest {
// Given
val fromStatus = buildSwapCurrencyStatus()
val toStatus = buildSwapCurrencyStatus()
val amount = SwapAmount(BigDecimal("0.5"), 18)
val provider = buildSwapProvider()
val swapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.1"), 18),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal("0.5"), 18),
toAmount = SwapAmount(BigDecimal("0.1"), 18),
txValue = "0",
txId = "tx-id-2",
txTo = "0xRecipient",
txExtraId = null,
txFrom = "0xSender",
txData = "dGVzdA==",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
),
)
val transactionSlot = slot<SavedSwapTransactionModel>()
// When
sut.storeSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = amount,
swapProvider = provider,
swapDataModel = swapDataModel,
timestamp = 1L,
txExternalUrl = null,
txExternalId = null,
averageDuration = null,
)
// Then
coVerify(exactly = 1) {
swapTransactionRepository.storeTransaction(
fromUserWalletId = any(),
toUserWalletId = any(),
fromCryptoCurrency = any(),
toCryptoCurrency = any(),
fromAccount = any(),
toAccount = any(),
transaction = capture(transactionSlot),
)
}
val status = requireNotNull(transactionSlot.captured.status)
assertThat(status.txExternalUrl).isNull()
assertThat(status.txExternalId).isNull()
assertThat(status.averageDuration).isNull()
}
}

View file

@ -0,0 +1,403 @@
package com.tangem.feature.swap.domain
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.appcurrency.repository.AppCurrencyRepository
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.models.SwapPairModel
import com.tangem.domain.swap.usecase.GetSwapPairUseCase
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
import com.tangem.feature.swap.domain.models.ui.TxFee
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import java.math.BigDecimal
/**
* Base class that wires all ~30 dependencies of [SwapInteractorImpl] as relaxed MockK mocks.
* Extend this in every test class and override individual stubs in `@BeforeEach` or within tests.
*/
internal open class SwapInteractorImplTestBase {
// region — mocked dependencies
protected val repository: SwapRepository = mockk(relaxed = true)
protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true)
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxed = true)
protected val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true)
protected val createTransactionUseCase: CreateTransactionUseCase = mockk(relaxed = true)
protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true)
protected val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
protected val isDemoCardUseCase: IsDemoCardUseCase = mockk(relaxed = true)
protected val quotesRepository: QuotesRepository = mockk(relaxed = true)
protected val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk(relaxed = true)
protected val swapTransactionRepository: SwapTransactionRepository = mockk(relaxed = true)
private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true)
private val appCurrencyRepository: AppCurrencyRepository = mockk(relaxed = true)
protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true)
protected val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true)
protected val validateTransactionUseCase: ValidateTransactionUseCase = mockk(relaxed = true)
protected val estimateFeeUseCase: EstimateFeeUseCase = mockk(relaxed = true)
protected val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase = mockk(relaxed = true)
protected val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase = mockk(relaxed = true)
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase =
mockk(relaxed = true)
protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true)
protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk(relaxed = true)
protected val getAssetRequirementsUseCase: GetAssetRequirementsUseCase = mockk(relaxed = true)
protected val amountFormatter: AmountFormatter = mockk(relaxed = true)
protected val rampStateManager: RampStateManager = mockk(relaxed = true)
protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase =
mockk(relaxed = true)
protected val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
protected val getAllowanceInfoUseCase: GetAllowanceInfoUseCase = mockk(relaxed = true)
protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true)
// endregion
protected val sut: SwapInteractorImpl by lazy {
SwapInteractorImpl(
repository = repository,
allowPermissionsHandler = allowPermissionsHandler,
cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher,
sendTransactionUseCase = sendTransactionUseCase,
createTransactionUseCase = createTransactionUseCase,
createTransferTransactionUseCase = createTransferTransactionUseCase,
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
isDemoCardUseCase = isDemoCardUseCase,
quotesRepository = quotesRepository,
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
swapTransactionRepository = swapTransactionRepository,
currencyChecksRepository = currencyChecksRepository,
appCurrencyRepository = appCurrencyRepository,
currenciesRepository = currenciesRepository,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
validateTransactionUseCase = validateTransactionUseCase,
estimateFeeUseCase = estimateFeeUseCase,
estimateFeeForTokenUseCase = estimateFeeForTokenUseCase,
estimateFeeForGaslessTxUseCase = estimateFeeForGaslessTxUseCase,
getFeeForTokenUseCase = getFeeForTokenUseCase,
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
getFeeUseCase = getFeeUseCase,
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
getCurrencyCheckUseCase = getCurrencyCheckUseCase,
getAssetRequirementsUseCase = getAssetRequirementsUseCase,
amountFormatter = amountFormatter,
rampStateManager = rampStateManager,
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
walletManagersFacade = walletManagersFacade,
getAllowanceInfoUseCase = getAllowanceInfoUseCase,
getSwapPairUseCase = getSwapPairUseCase,
)
}
/**
* Clears recorded calls and stubbed answers on all MockK mocks AND releases any
* `mockkStatic` / `mockkObject` declarations between tests.
*
* - `clearAllMocks()` wipes recorded calls and stubbed answers; relaxed mocks remain relaxed
* (creation-time property). Each test must (re)stub any required behavior in its own
* `@BeforeEach` or test body.
* - `unmockkAll()` releases static/object mocks set up inline by some tests
* (e.g. `mockkStatic(Base64::class)`, `mockkObject(SolanaTransactionHelper)`) so leaks
* do not propagate across tests within the same class.
*/
@AfterEach
open fun clearMocksAfterEachTest() {
clearAllMocks()
unmockkAll()
}
/**
* Defensive shutdown hook releases any remaining `mockkStatic` / `mockkObject` declarations
* after the entire test class finishes, in case `@AfterEach` was bypassed (e.g. JVM shutdown
* during a hard crash).
*
* Requires `@TestInstance(Lifecycle.PER_CLASS)` on every subclass already the case across
* all `SwapInteractorImpl*Test` classes.
*/
@AfterAll
open fun releaseStaticMocksAfterAllTests() {
unmockkAll()
}
}
// region — Test Builders
/**
* Builds a [SwapCurrencyStatus] backed entirely by relaxed mocks.
*
* The [Network] mock is fully relaxed [Network.rawId] is stubbed to return [networkRawId].
* The extension function [com.tangem.blockchainsdk.utils.toBlockchain] is not stubbed here;
* call-sites that need a specific Blockchain should use [io.mockk.mockkStatic] around the test.
*
* @param networkRawId raw network id use `Blockchain.Ethereum.toNetworkId()` for EVM
* @param contractAddress "0" for native coins, a real contract address for tokens
* @param isCoin true to make the currency a [CryptoCurrency.Coin], false for [CryptoCurrency.Token]
* @param amount token balance to expose via [CryptoCurrencyStatus.Value.amount]
*/
internal fun buildSwapCurrencyStatus(
networkRawId: String = Blockchain.Ethereum.toNetworkId(),
contractAddress: String = "0",
isCoin: Boolean = true,
amount: BigDecimal = BigDecimal("1"),
decimals: Int = 18,
userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"),
yieldSupplyActive: Boolean = false,
): SwapCurrencyStatus {
val networkId = mockk<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId)
}
val network = mockk<Network>(relaxed = true) {
every { rawId } returns networkRawId
every { id } returns networkId
every { derivationPath } returns Network.DerivationPath.None
}
val currencyId = mockk<CryptoCurrency.ID>(relaxed = true) {
every { rawCurrencyId } returns CryptoCurrency.RawID(contractAddress)
}
val currency: CryptoCurrency = if (isCoin) {
mockk<CryptoCurrency.Coin>(relaxed = true) {
every { this@mockk.network } returns network
every { this@mockk.decimals } returns decimals
every { this@mockk.id } returns currencyId
}
} else {
mockk<CryptoCurrency.Token>(relaxed = true) {
every { this@mockk.network } returns network
every { this@mockk.decimals } returns decimals
every { this@mockk.contractAddress } returns contractAddress
every { this@mockk.id } returns currencyId
}
}
val networkAddress = mockk<NetworkAddress>(relaxed = true) {
every { defaultAddress } returns NetworkAddress.Address(
value = "0xTestAddress",
type = NetworkAddress.Address.Type.Primary,
)
}
val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) {
mockk<YieldSupplyStatus>(relaxed = true) {
every { isActive } returns true
}
} else {
null
}
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(relaxed = true) {
every { this@mockk.amount } returns amount
every { this@mockk.networkAddress } returns networkAddress
every { this@mockk.pendingTransactions } returns emptySet()
every { this@mockk.yieldSupplyStatus } returns maybeYield
}
val cryptoCurrencyStatus = CryptoCurrencyStatus(
currency = currency,
value = statusValue,
)
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
val account = mockk<Account>(relaxed = true) {
every { accountId } returns mockk(relaxed = true)
}
return SwapCurrencyStatus(
userWallet = userWallet,
status = cryptoCurrencyStatus,
account = account,
)
}
/**
* Builds a mocked [CryptoCurrency.Coin] with a stubbed network. Used where APIs require the concrete Coin subtype.
*/
internal fun buildCoinCurrency(
networkRawId: String = Blockchain.Ethereum.toNetworkId(),
decimals: Int = 18,
): CryptoCurrency.Coin {
val networkId = mockk<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId)
}
val network = mockk<Network>(relaxed = true) {
every { rawId } returns networkRawId
every { id } returns networkId
every { derivationPath } returns Network.DerivationPath.None
}
val currencyId = mockk<CryptoCurrency.ID>(relaxed = true) {
every { rawCurrencyId } returns CryptoCurrency.RawID("0")
}
return mockk<CryptoCurrency.Coin>(relaxed = true) {
every { this@mockk.network } returns network
every { this@mockk.decimals } returns decimals
every { this@mockk.id } returns currencyId
}
}
/**
* Builds a [SwapProvider] for a given [ExchangeProviderType].
*/
internal fun buildSwapProvider(
type: ExchangeProviderType = ExchangeProviderType.DEX,
providerId: String = "test-provider-${type.name}",
): SwapProvider = SwapProvider(
providerId = providerId,
rateTypes = listOf(RateType.FLOAT),
name = "TestProvider-${type.name}",
type = type,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
isRecommended = false,
slippage = null,
isExtraIdSupported = false,
)
/**
* Builds a [TxFee.FeeComponent] wrapping a [Fee.Common] with the given fiat-equivalent amount.
*/
internal fun buildTxFee(
feeValue: BigDecimal = BigDecimal("0.001"),
selectedToken: CryptoCurrencyStatus? = null,
): TxFee.FeeComponent {
val amount = mockk<Amount>(relaxed = true) {
every { value } returns feeValue
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns amount
}
return TxFee.FeeComponent(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(
fee = mockk<TransactionFee.Single>(relaxed = true),
),
selectedToken = selectedToken,
)
}
/**
* Builds a [TxFeeSealedState.Component] wrapping a [TxFee.FeeComponent].
*/
internal fun buildTxFeeSealedState(
feeValue: BigDecimal = BigDecimal("0.001"),
selectedToken: CryptoCurrencyStatus? = null,
): TxFeeSealedState = TxFeeSealedState.Component(
txFee = buildTxFee(feeValue = feeValue, selectedToken = selectedToken),
)
/**
* Builds a [SwapPairLeast] with matching from/to network+contract pairs.
*/
internal fun buildSwapPairLeast(
fromNetwork: String = Blockchain.Ethereum.toNetworkId(),
fromContract: String = "0",
toNetwork: String = Blockchain.Bitcoin.toNetworkId(),
toContract: String = "0",
providers: List<SwapProvider> = listOf(buildSwapProvider()),
): SwapPairLeast = SwapPairLeast(
from = LeastTokenInfo(contractAddress = fromContract, network = fromNetwork),
to = LeastTokenInfo(contractAddress = toContract, network = toNetwork),
providers = providers,
)
/**
* Builds a [QuoteModel] with optional allowance contract.
*/
internal fun buildQuoteModel(
toAmount: BigDecimal = BigDecimal("0.5"),
decimals: Int = 18,
allowanceContract: String? = null,
): QuoteModel = QuoteModel(
toTokenAmount = SwapAmount(toAmount, decimals),
allowanceContract = allowanceContract,
)
/**
* Builds an [ExpressProvider] used by [GetSwapPairUseCase] results.
*/
internal fun buildExpressProvider(
providerId: String = "express-provider",
type: ExpressProviderType = ExpressProviderType.DEX,
): ExpressProvider = ExpressProvider(
providerId = providerId,
rateTypes = listOf(ExpressRateType.Float),
name = "ExpressProvider-${type.name}",
type = type,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
isRecommended = false,
slippage = null,
isExtraIdSupported = false,
)
/**
* Builds a [SwapPairModel] used as the result type of [GetSwapPairUseCase].
*/
internal fun buildSwapPairModel(
fromNetworkRawId: String = Blockchain.Ethereum.toNetworkId(),
fromContractAddress: String = "0",
toNetworkRawId: String = Blockchain.Bitcoin.toNetworkId(),
toContractAddress: String = "0",
providers: List<ExpressProvider> = listOf(buildExpressProvider()),
): SwapPairModel {
val fromCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = fromNetworkRawId,
contractAddress = fromContractAddress,
)
val toCurrencyStatus = buildSwapCurrencyStatus(
networkRawId = toNetworkRawId,
contractAddress = toContractAddress,
)
return SwapPairModel(
from = fromCurrencyStatus.status,
to = toCurrencyStatus.status,
providers = providers,
)
}
// endregion

View file

@ -1,5 +1,15 @@
package com.tangem.feature.swap
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.swap.SwapFeatureToggles
import javax.inject.Inject
internal class DefaultSwapFeatureToggles : SwapFeatureToggles
internal class DefaultSwapFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : SwapFeatureToggles {
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_SWITCH_TO_TRANSFER_ENABLED,
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.feature.swap.DefaultSwapComponent
import com.tangem.feature.swap.DefaultSwapFeatureToggles
import com.tangem.features.swap.SwapComponent
@ -17,8 +18,8 @@ internal object SwapFeatureModule {
@Provides
@Singleton
fun provideSwapFeatureToggles(): SwapFeatureToggles {
return DefaultSwapFeatureToggles()
fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles {
return DefaultSwapFeatureToggles(featureTogglesManager)
}
}

View file

@ -98,6 +98,7 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.swap.SwapComponent
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.isNullOrZero
@ -144,13 +145,14 @@ internal class SwapModel @Inject constructor(
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
private val appsFlyerStore: AppsFlyerStore,
private val messageSender: UiMessageSender,
private val initialCurrenciesResolver: InitialCurrenciesResolver,
private val allowPermissionsHandler: AllowPermissionsHandler,
private val swapFeatureToggles: SwapFeatureToggles,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -184,7 +186,7 @@ internal class SwapModel @Inject constructor(
isBalanceHiddenProvider = Provider { isBalanceHidden },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isAccountsModeProvider = Provider { isAccountsMode },
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
)
private val inputNumberFormatter = InputNumberFormatter(
@ -1548,6 +1550,7 @@ internal class SwapModel @Inject constructor(
}
private fun filterTokensFromSelector() {
if (swapFeatureToggles.isSwapSwitchToTransferEnabled) return
val tokenFilter = { accountStatus: AccountStatus, currencyStatus: CryptoCurrencyStatus ->
if (currencyStatus.currency.isCustom) {
false

View file

@ -35,7 +35,7 @@ import java.math.BigDecimal
@Suppress("LargeClass")
internal class SwapNotificationsFactory(
private val actions: UiActions,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
) {
fun getGeneralErrorStateNotifications(
@ -314,7 +314,7 @@ internal class SwapNotificationsFactory(
val isNotEnoughFee =
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) &&
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) &&
quoteModel.swapProvider.type == ExchangeProviderType.CEX
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
add(

View file

@ -57,12 +57,12 @@ internal class StateBuilder(
private val isBalanceHiddenProvider: Provider<Boolean>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isAccountsModeProvider: Provider<Boolean>,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork)
SwapNotificationsFactory(actions, isGaslessFeeSupportedForNetwork)
}
fun createInitialLoadingState(): SwapStateHolder {

View file

@ -30,7 +30,7 @@ internal class StateBuilderInitialStateTest {
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
@ -47,7 +47,7 @@ internal class StateBuilderInitialStateTest {
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
)
}

View file

@ -25,7 +25,7 @@ internal class StateBuilderPairsTest {
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
@ -52,7 +52,7 @@ internal class StateBuilderPairsTest {
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
)
}

View file

@ -2,7 +2,6 @@ package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
@ -14,12 +13,10 @@ import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@ -31,7 +28,7 @@ internal class StateBuilderQuotesTest {
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
@ -52,14 +49,14 @@ internal class StateBuilderQuotesTest {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns AppCurrency.Default
every { isAccountsModeProvider() } returns false
every { iGaslessFeeSupportedForNetwork(any()) } returns false
every { isGaslessFeeSupportedForNetwork(any()) } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
)
}

View file

@ -4,13 +4,11 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
@ -30,7 +28,7 @@ internal class StateBuilderSwapDataTest {
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
@ -51,14 +49,14 @@ internal class StateBuilderSwapDataTest {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns AppCurrency.Default
every { isAccountsModeProvider() } returns false
every { iGaslessFeeSupportedForNetwork(any()) } returns false
every { isGaslessFeeSupportedForNetwork(any()) } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
)
}

View file

@ -243,10 +243,6 @@ internal class WalletModel @Inject constructor(
} else {
null
}
val isBackedUp = when (selectedWallet) {
is UserWallet.Cold -> selectedWallet.scanResponse.card.backupStatus?.isActive == true
is UserWallet.Hot -> selectedWallet.backedUp
}
val result = getAppThemeModeUseCase().firstOrNull()
val theme = result?.getOrElse { AppThemeMode.FOLLOW_SYSTEM } ?: AppThemeMode.FOLLOW_SYSTEM
val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { AppCurrency.Default }.code
@ -254,7 +250,7 @@ internal class WalletModel @Inject constructor(
WalletScreenAnalyticsEvent.MainScreen.ScreenOpened(
hasMobileWallet = hasMobileWallet,
accountsCount = accountsCount,
isBackedUp = isBackedUp,
isBackedUp = selectedWallet.isBackedUpForAnalytics(),
theme = theme.value,
isImported = selectedWallet.isImported(),
referralId = appsFlyerStore.get()?.refcode,

View file

@ -1,56 +1,14 @@
package com.tangem.feature.wallet.presentation.wallet.analytics
import com.tangem.core.analytics.models.*
import com.tangem.domain.models.wallet.UserWalletId
sealed class WalletScreenAnalyticsEvent {
sealed class Basic(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Basic", event = event, params = params) {
class WalletToppedUp(userWalletId: UserWalletId, walletType: AnalyticsParam.WalletType) :
Basic(
event = "Topped up",
params = mapOf(AnalyticsParam.CURRENCY to walletType.value),
),
OneTimeAnalyticsEvent, AppsFlyerIncludedEvent {
override val oneTimeEventId: String = id + userWalletId.stringValue
}
class CardWasScanned(source: AnalyticsParam.ScreensSources) : Basic(
event = "Card Was Scanned",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
),
)
class BalanceLoaded(balance: AnalyticsParam.CardBalanceState, tokensCount: Int?) : Basic(
event = "Balance Loaded",
params = buildMap {
put(AnalyticsParam.BALANCE, balance.value)
tokensCount?.let { put(AnalyticsParam.TOKENS_COUNT, it.toString()) }
},
), AppsFlyerIncludedEvent
class TokenBalance(balance: AnalyticsParam.EmptyFull, token: String) : Basic(
event = "Token Balance",
params = mapOf(
AnalyticsParam.STATE to balance.value,
AnalyticsParam.TOKEN_PARAM to token,
),
)
}
sealed class MainScreen(
event: String,
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Main Screen", event = event, params = params) {
class ScreenOpenedLegacy : MainScreen(event = "Screen opened")
data class ScreenOpened(
private val hasMobileWallet: Boolean,
private val accountsCount: Int?,

View file

@ -8,6 +8,7 @@ import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.extensions.isZero
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.analytics.CheckIsWalletToppedUpUseCase
@ -18,7 +19,6 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.MainScreen
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider
@ -206,7 +206,12 @@ internal class TokenListAnalyticsSender @Inject constructor(
AnalyticsParam.WalletType.SingleCurrency(currency.currency.name)
}
analyticsEventHandler.send(Basic.WalletToppedUp(userWallet.walletId, walletType))
analyticsEventHandler.send(
Basic.ToppedUp(
userWalletId = userWallet.walletId.stringValue,
walletType = walletType,
),
)
}
}

View file

@ -3,12 +3,12 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers
import com.tangem.common.extensions.isZero
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetPrimaryCurrencyTransformer
import dagger.assisted.Assisted
@ -70,7 +70,7 @@ internal class PrimaryCurrencySubscriber @AssistedInject constructor(
cardBalanceState?.let { balanceState ->
// do not send tokens count for single currency wallet
analyticsEventHandler.send(
event = WalletScreenAnalyticsEvent.Basic.BalanceLoaded(
event = Basic.BalanceLoaded(
balance = balanceState,
tokensCount = null,
),

View file

@ -209,16 +209,27 @@ private fun WalletContent2(
.fillMaxSize()
.hazeSourceTangem(zIndex = -2f),
) {
NorthernLightsBackground(
containerColor = if (LocalIsInDarkTheme.current) {
TangemTheme.colors2.surface.level1
} else {
TangemTheme.colors2.surface.level2
},
val backgroundColor = if (LocalIsInDarkTheme.current) {
TangemTheme.colors2.surface.level1
} else {
TangemTheme.colors2.surface.level2
}
Box(
modifier = Modifier
.graphicsLayer { alpha = 1 - behavior.state.collapsedFraction * 2 }
.matchParentSize(),
.matchParentSize()
.background(backgroundColor),
)
val isSheetExpanded by remember {
derivedStateOf { bottomSheetState.targetValue == TangemSheetValue.Expanded }
}
if (!isSheetExpanded) {
NorthernLightsBackground(
containerColor = backgroundColor,
modifier = Modifier
.graphicsLayer { alpha = 1 - behavior.state.collapsedFraction * 2 }
.matchParentSize(),
)
}
WalletPagerIndicator(
pagerState = walletsPagerState,

View file

@ -34,6 +34,7 @@ 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.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.tokenlist.NON_CONTENT_TOKENS_LIST_KEY
import com.tangem.core.ui.components.tokenlist.TokenListItem
@ -382,12 +383,14 @@ internal fun PortfolioRowItem(
headIcon
}
val iconBoxSize = when (headIcon) {
is TangemIconUM.Empty -> TangemTheme.dimens2.x9
else -> size.toBoxSize()
}
TangemIcon(
tangemIconUM = sizedHeadIcon,
modifier = modifier
.conditionalCompose(headIcon is TangemIconUM.Empty) {
size(TangemTheme.dimens2.x9)
}
.size(iconBoxSize)
.sharedBounds(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = animatedContentScope,

View file

@ -19,10 +19,7 @@ import com.tangem.domain.card.ScanCardProcessor
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.SaveWalletError
import com.tangem.domain.common.wallets.error.UnlockWalletError
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isImported
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.*
import com.tangem.domain.settings.CanUseBiometryUseCase
import com.tangem.domain.settings.HotWalletRestrictionManager
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
@ -76,9 +73,9 @@ internal class WelcomeModel @Inject constructor(
val userWallet = userWallets.first { it.walletId == walletId }
trackingContextProxy.addContext(userWallet)
val signInType = when {
!userWallet.isLocked -> SignIn.ButtonWallet.SignInType.NoSecurity
userWallet is UserWallet.Cold -> SignIn.ButtonWallet.SignInType.Card
else -> SignIn.ButtonWallet.SignInType.AccessCode
!userWallet.isLocked -> AnalyticsParam.SignInType.NoSecurity
userWallet is UserWallet.Cold -> AnalyticsParam.SignInType.Card
else -> AnalyticsParam.SignInType.AccessCode
}
analyticsEventHandler.send(
event = SignIn.ButtonWallet(
@ -259,7 +256,7 @@ internal class WelcomeModel @Inject constructor(
if (userWallet.isLocked.not()) {
// If the wallet is not locked, we can proceed to the wallet screen directly
userWalletsListRepository.select(userWallet.walletId)
trackSignInEvent(userWallet, Basic.SignedIn.SignInType.NoSecurity)
trackSignInEvent(userWallet, AnalyticsParam.SignInType.NoSecurity)
router.replaceAll(AppRoute.Wallet)
return@launch
}
@ -320,19 +317,15 @@ internal class WelcomeModel @Inject constructor(
}
}
private suspend fun trackSignInEvent(userWallet: UserWallet, type: Basic.SignedIn.SignInType) {
private suspend fun trackSignInEvent(userWallet: UserWallet, type: AnalyticsParam.SignInType) {
val walletsCount = userWalletsListRepository.userWalletsSync().size
trackingContextProxy.addContext(userWallet)
val isBackedUp = when (userWallet) {
is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus?.isActive == true
is UserWallet.Hot -> userWallet.backedUp
}
analyticsEventHandler.send(
event = Basic.SignedIn(
signInType = type,
walletsCount = walletsCount,
isImported = userWallet.isImported(),
hasBackup = isBackedUp,
isBackedUp = userWallet.isBackedUpForAnalytics(),
),
)
}