Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-07 13:07:21 +05:00
commit fce19374d4
185 changed files with 7959 additions and 1134 deletions

View file

@ -187,8 +187,8 @@ internal class ChooseTokenListItemConverter(
is PaymentAccountStatusValue.UnderReview,
PaymentAccountStatusValue.Loading,
PaymentAccountStatusValue.Empty,
is PaymentAccountStatusValue.Deactivated,
-> return null
is PaymentAccountStatusValue.Deactivated -> status.cryptoCurrencyStatus
is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus
}
val account = this.account

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

@ -23,6 +23,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
@ -152,7 +153,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

@ -1,23 +1,16 @@
package com.tangem.features.staking.impl.presentation.state.converters
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.SECONDS_IN_HOUR
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.BalanceType
import com.tangem.domain.models.staking.PendingAction
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingBalanceEntry
import com.tangem.domain.models.staking.StakingEntryActions
import com.tangem.domain.models.staking.StakingEntryType
import com.tangem.domain.models.staking.*
import com.tangem.domain.models.staking.action.StakingActionType
import com.tangem.domain.staking.model.Period
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.StakingTarget
import com.tangem.features.staking.impl.R
@ -125,12 +118,26 @@ internal class StakingBalanceEntryConverter(
}
}
StakingEntryType.PREPARING -> {
val warmupPeriod = integration.warmupPeriodDays
val warmupPeriod = integration.warmupPeriod
TextReference.Combined(
wrappedList(
resourceReference(R.string.staking_details_warmup_period),
stringReference(" "),
pluralReference(R.plurals.common_days, warmupPeriod, wrappedList(warmupPeriod)),
when (warmupPeriod) {
is Period.Days -> pluralReference(
id = R.plurals.common_days,
count = warmupPeriod.value,
formatArgs = wrappedList(warmupPeriod.value),
)
is Period.Seconds -> {
val hours = warmupPeriod.value / SECONDS_IN_HOUR
pluralReference(
id = R.plurals.common_hours,
count = hours,
formatArgs = wrappedList(hours),
)
}
},
),
)
}

View file

@ -7,18 +7,20 @@ import com.tangem.common.ui.amountScreen.converters.AmountStateConverter
import com.tangem.common.ui.amountScreen.models.AmountParameters
import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.list.RoundedListWithDividersItemData
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.utils.SECONDS_IN_HOUR
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.StakingBalanceEntry
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.staking.model.Period
import com.tangem.domain.staking.model.StakingIntegration
import com.tangem.domain.staking.model.StakingTarget
import com.tangem.domain.staking.model.common.RewardClaiming
@ -199,17 +201,27 @@ internal class SetInitialDataStateTransformer(
}
private fun createWarmupPeriodItem(): RoundedListWithDividersItemData? {
val warmupPeriodDays = integration.warmupPeriodDays
if (warmupPeriodDays == 0) return null
val warmupPeriod = integration.warmupPeriod
if (warmupPeriod.value == 0) return null
return RoundedListWithDividersItemData(
id = R.string.staking_details_warmup_period,
startText = TextReference.Res(R.string.staking_details_warmup_period),
endText = pluralReference(
id = R.plurals.common_days,
count = warmupPeriodDays,
formatArgs = wrappedList(warmupPeriodDays),
),
endText = when (warmupPeriod) {
is Period.Days -> pluralReference(
id = R.plurals.common_days,
count = warmupPeriod.value,
formatArgs = wrappedList(warmupPeriod.value),
)
is Period.Seconds -> {
val hours = warmupPeriod.value / SECONDS_IN_HOUR
pluralReference(
id = R.plurals.common_hours,
count = hours,
formatArgs = wrappedList(hours),
)
}
},
iconClick = { clickIntents.onInfoClick(InfoType.WARMUP_PERIOD) },
)
}

View file

@ -1,22 +1,30 @@
package com.tangem.features.staking.impl.presentation.state.utils
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.utils.SECONDS_IN_HOUR
import com.tangem.domain.staking.model.CooldownPeriod
import com.tangem.domain.staking.model.Period
import com.tangem.features.staking.impl.R
import com.tangem.utils.StringsSigns.MINUS
import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE
internal fun CooldownPeriod.toTextReference(): TextReference {
return when (this) {
is CooldownPeriod.Fixed -> pluralReference(
id = R.plurals.common_days,
count = days,
formatArgs = wrappedList(days),
)
is CooldownPeriod.Fixed -> when (period) {
is Period.Days -> pluralReference(
id = R.plurals.common_days,
count = period.value,
formatArgs = wrappedList(period.value),
)
is Period.Seconds -> {
val hours = period.value / SECONDS_IN_HOUR
pluralReference(
id = R.plurals.common_hours,
count = hours,
formatArgs = wrappedList(hours),
)
}
}
is CooldownPeriod.Range -> combinedReference(
stringReference("$minDays$MINUS$maxDays$NON_BREAKING_SPACE"),
pluralReference(

View file

@ -0,0 +1,221 @@
package com.tangem.features.staking.impl.presentation.state.utils
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.SECONDS_IN_HOUR
import com.tangem.domain.staking.model.CooldownPeriod
import com.tangem.domain.staking.model.Period
import com.tangem.features.staking.impl.R
import com.tangem.utils.StringsSigns.MINUS
import com.tangem.utils.StringsSigns.NON_BREAKING_SPACE
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for [CooldownPeriod.toTextReference] extension function.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CooldownPeriodUtilsTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class `Fixed with Days` {
@Test
fun `should return plural days reference for Fixed Period Days`() {
// given
val cooldown = CooldownPeriod.Fixed(Period.Days(7))
// when
val result = cooldown.toTextReference()
// then
val expected = pluralReference(
id = R.plurals.common_days,
count = 7,
formatArgs = wrappedList(7),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `should return plural days with zero for Fixed Period Days zero`() {
// given
val cooldown = CooldownPeriod.Fixed(Period.Days(0))
// when
val result = cooldown.toTextReference()
// then
val expected = pluralReference(
id = R.plurals.common_days,
count = 0,
formatArgs = wrappedList(0),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `should pass day count as both count and format arg`() {
// given
val days = 14
val cooldown = CooldownPeriod.Fixed(Period.Days(days))
// when
val result = cooldown.toTextReference()
// then
assertThat(result).isInstanceOf(TextReference.PluralRes::class.java)
val plural = result as TextReference.PluralRes
assertThat(plural.count).isEqualTo(days)
assertThat(plural.formatArgs.first()).isEqualTo(days)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class `Fixed with Seconds` {
@Test
fun `should return plural hours reference for Fixed Period Seconds`() {
// given — 2 hours worth of seconds
val cooldown = CooldownPeriod.Fixed(Period.Seconds(2 * SECONDS_IN_HOUR))
// when
val result = cooldown.toTextReference()
// then
val expectedHours = 2
val expected = pluralReference(
id = R.plurals.common_hours,
count = expectedHours,
formatArgs = wrappedList(expectedHours),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `should convert exactly one hour worth of seconds to 1 hour`() {
// given
val cooldown = CooldownPeriod.Fixed(Period.Seconds(SECONDS_IN_HOUR))
// when
val result = cooldown.toTextReference()
// then
assertThat(result).isInstanceOf(TextReference.PluralRes::class.java)
val plural = result as TextReference.PluralRes
assertThat(plural.count).isEqualTo(1)
assertThat(plural.formatArgs.first()).isEqualTo(1)
}
@Test
fun `should floor to 1 hour when seconds are not divisible evenly`() {
// given — 5000 seconds = 1.388... hours → integer division → 1
val cooldown = CooldownPeriod.Fixed(Period.Seconds(5000))
// when
val result = cooldown.toTextReference()
// then
assertThat(result).isInstanceOf(TextReference.PluralRes::class.java)
val plural = result as TextReference.PluralRes
assertThat(plural.count).isEqualTo(1)
}
@Test
fun `should return 0 hours for zero seconds`() {
// given
val cooldown = CooldownPeriod.Fixed(Period.Seconds(0))
// when
val result = cooldown.toTextReference()
// then
assertThat(result).isInstanceOf(TextReference.PluralRes::class.java)
val plural = result as TextReference.PluralRes
assertThat(plural.count).isEqualTo(0)
assertThat(plural.formatArgs.first()).isEqualTo(0)
}
@Test
fun `should use R plurals common_hours resource id`() {
// given
val cooldown = CooldownPeriod.Fixed(Period.Seconds(SECONDS_IN_HOUR))
// when
val result = cooldown.toTextReference()
// then
assertThat(result).isInstanceOf(TextReference.PluralRes::class.java)
assertThat((result as TextReference.PluralRes).id).isEqualTo(R.plurals.common_hours)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Range {
@Test
fun `should return combined reference for Range`() {
// given
val minDays = 2
val maxDays = 5
val cooldown = CooldownPeriod.Range(minDays = minDays, maxDays = maxDays)
// when
val result = cooldown.toTextReference()
// then
val expected = combinedReference(
stringReference("$minDays$MINUS$maxDays$NON_BREAKING_SPACE"),
pluralReference(
id = R.plurals.common_days_no_param,
count = maxDays,
),
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `should use maxDays as count for plural in Range`() {
// given
val maxDays = 10
val cooldown = CooldownPeriod.Range(minDays = 3, maxDays = maxDays)
// when
val result = cooldown.toTextReference()
// then
assertThat(result).isInstanceOf(TextReference.Combined::class.java)
val combined = result as TextReference.Combined
val pluralPart = combined.refs[1]
assertThat(pluralPart).isInstanceOf(TextReference.PluralRes::class.java)
assertThat((pluralPart as TextReference.PluralRes).count).isEqualTo(maxDays)
}
@Test
fun `should include minDays and maxDays with minus and non-breaking-space in string part`() {
// given
val minDays = 1
val maxDays = 7
val cooldown = CooldownPeriod.Range(minDays = minDays, maxDays = maxDays)
// when
val result = cooldown.toTextReference()
// then
assertThat(result).isInstanceOf(TextReference.Combined::class.java)
val combined = result as TextReference.Combined
val stringPart = combined.refs[0]
assertThat(stringPart).isInstanceOf(TextReference.Str::class.java)
assertThat((stringPart as TextReference.Str).value)
.isEqualTo("$minDays$MINUS$maxDays$NON_BREAKING_SPACE")
}
}
}

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

@ -130,6 +130,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List<CryptoCurrencyStatus> {
val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) {
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
is PaymentAccountStatusValue.Deactivated -> statusValue.cryptoCurrencyStatus
else -> null
}

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

@ -2,10 +2,9 @@ package com.tangem.features.tangempay.components
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.models.account.AccountStatus
interface TangemPayDetailsContainerComponent : ComposableContentComponent {
data class Params(val userWalletId: UserWalletId, val config: TangemPayDetailsConfig)
data class Params(val initialStatus: AccountStatus.Payment)
interface Factory : ComponentFactory<Params, TangemPayDetailsContainerComponent>
}

View file

@ -67,7 +67,7 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru
)
TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create(
context = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayCardPageComponent.Params(userWalletId = params.userWalletId, config = params.config),
params = TangemPayCardPageComponent.Params(initialStatus = params.initialStatus),
)
TangemPayAccountDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.features.tangempay.model.TangemPayAddFundsModel
@ -32,7 +33,7 @@ internal class TangemPayAddFundsComponent(
val cryptoBalance: BigDecimal,
val fiatBalance: BigDecimal,
val depositAddress: String,
val chainId: Int,
val cryptoCurrency: CryptoCurrency,
)
}

View file

@ -13,6 +13,8 @@ import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCard
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.model.TangemPayAddToWalletModel
import com.tangem.features.tangempay.ui.TangemPayAddToWalletScreen
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
internal class TangemPayAddToWalletComponent(
private val appComponentContext: AppComponentContext,
@ -24,7 +26,8 @@ internal class TangemPayAddToWalletComponent(
private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent(
appComponentContext = child("cardDetailsBlockComponent"),
params = TangemPayCardDetailsBlockComponent.Params(
params = params,
card = params.initialStatus.firstCard(),
userWalletId = params.initialStatus.userWalletId,
isEditingNameEnabled = false,
),
)

View file

@ -16,8 +16,7 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.models.account.AccountStatus
import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupComponent
import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupSuccessComponent
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
@ -70,34 +69,22 @@ internal class TangemPayCardPageComponent @AssistedInject constructor(
)
TangemPayCardDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayDetailsContainerComponent.Params(
userWalletId = params.userWalletId,
config = params.config,
),
params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus),
)
TangemPayCardDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
)
TangemPayCardDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayDetailsContainerComponent.Params(
userWalletId = params.userWalletId,
config = params.config,
),
params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus),
)
TangemPayCardDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayDetailsContainerComponent.Params(
userWalletId = params.userWalletId,
config = params.config,
),
params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus),
)
TangemPayCardDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
params = TangemPayDetailsContainerComponent.Params(
userWalletId = params.userWalletId,
config = params.config,
),
params = TangemPayDetailsContainerComponent.Params(initialStatus = params.initialStatus),
)
TangemPayCardDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent(
appComponentContext = childByContext(componentContext = componentContext, router = innerRouter),
@ -112,7 +99,7 @@ internal class TangemPayCardPageComponent @AssistedInject constructor(
}
}
data class Params(val userWalletId: UserWalletId, val config: TangemPayDetailsConfig)
data class Params(val initialStatus: AccountStatus.Payment)
@AssistedFactory
interface Factory : ComponentFactory<Params, TangemPayCardPageComponent> {

View file

@ -20,6 +20,8 @@ import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetails
import com.tangem.features.tangempay.entity.TangemPayCardNavigation
import com.tangem.features.tangempay.model.TangemPayCardPageModel
import com.tangem.features.tangempay.ui.TangemPayCardPageScreen
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.features.tokenreceive.TokenReceiveComponent
internal class TangemPayCardPageScreenComponent(
@ -30,15 +32,11 @@ internal class TangemPayCardPageScreenComponent(
private val model: TangemPayCardPageModel = getOrCreateModel(params = params)
private val containerParams = TangemPayDetailsContainerComponent.Params(
userWalletId = params.userWalletId,
config = params.config,
)
private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent(
appComponentContext = child("cardDetailsBlockComponent"),
params = TangemPayCardDetailsBlockComponent.Params(
params = containerParams,
card = params.initialStatus.firstCard(),
userWalletId = params.initialStatus.userWalletId,
isEditingNameEnabled = true,
),
)
@ -86,8 +84,8 @@ internal class TangemPayCardPageScreenComponent(
appComponentContext = context,
params = TangemPayReissueCardComponent.Params(
listener = model,
userWalletId = params.userWalletId,
cardId = params.config.cardId,
userWalletId = params.initialStatus.userWalletId,
cardId = params.initialStatus.firstCard().id,
),
)
is TangemPayCardNavigation.AddFunds -> TangemPayAddFundsComponent(
@ -98,7 +96,7 @@ internal class TangemPayCardPageScreenComponent(
cryptoBalance = navigation.cryptoBalance,
fiatBalance = navigation.fiatBalance,
depositAddress = navigation.depositAddress,
chainId = navigation.chainId,
cryptoCurrency = navigation.cryptoCurrency,
),
)
is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create(

View file

@ -22,6 +22,8 @@ import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDeta
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.ui.TangemPayDetailsScreen
import com.tangem.features.tangempay.utils.requireLoaded
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.features.tokenreceive.TokenReceiveComponent
internal class TangemPayDetailsComponent(
@ -42,7 +44,7 @@ internal class TangemPayDetailsComponent(
private val txHistoryComponent = DefaultTangemPayTxHistoryComponent(
appComponentContext = child("txHistoryComponent"),
params = DefaultTangemPayTxHistoryComponent.Params(
userWalletId = params.userWalletId,
userWalletId = params.initialStatus.userWalletId,
uiActions = model,
),
)
@ -50,7 +52,7 @@ internal class TangemPayDetailsComponent(
private val expressTransactionsComponent by lazy {
expressTransactionsComponentProvider.create(
appComponentContext = child("expressTransactionsComponent"),
userWalletId = params.userWalletId,
userWalletId = params.initialStatus.userWalletId,
cryptoCurrency = model.cryptoCurrency,
)
}
@ -95,8 +97,8 @@ internal class TangemPayDetailsComponent(
params = TangemPayTxHistoryDetailsComponent.Params(
transaction = navigation.transaction,
isBalanceHidden = navigation.isBalanceHidden,
userWalletId = params.userWalletId,
customerId = params.config.customerId,
userWalletId = params.initialStatus.userWalletId,
customerId = params.initialStatus.requireLoaded().customerId,
onDismiss = model.bottomSheetNavigation::dismiss,
),
)
@ -107,7 +109,7 @@ internal class TangemPayDetailsComponent(
cryptoBalance = navigation.cryptoBalance,
fiatBalance = navigation.fiatBalance,
depositAddress = navigation.depositAddress,
chainId = navigation.chainId,
cryptoCurrency = navigation.cryptoCurrency,
listener = model,
),
)

View file

@ -14,6 +14,8 @@ import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetails
import com.tangem.features.tangempay.entity.DisplayNameState
import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel
import com.tangem.features.tangempay.ui.TangemPayEditDisplayNameScreen
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
internal class TangemPayEditDisplayNameComponent(
private val appComponentContext: AppComponentContext,
@ -24,7 +26,11 @@ internal class TangemPayEditDisplayNameComponent(
private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent(
appComponentContext = child("editDisplayNameCardDetails"),
params = TangemPayCardDetailsBlockComponent.Params(params = params, isEditingNameEnabled = false),
params = TangemPayCardDetailsBlockComponent.Params(
card = params.initialStatus.firstCard(),
userWalletId = params.initialStatus.userWalletId,
isEditingNameEnabled = false,
),
)
@Composable

View file

@ -3,7 +3,8 @@ package com.tangem.features.tangempay.components.cardDetails
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM
import kotlinx.coroutines.flow.StateFlow
@ -15,7 +16,8 @@ internal interface TangemPayCardDetailsBlockComponent {
fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier)
data class Params(
val params: TangemPayDetailsContainerComponent.Params,
val card: TangemPayCard,
val userWalletId: UserWalletId,
val isEditingNameEnabled: Boolean,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.features.tangempay.entity
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.serialization.Serializable
@ -22,7 +23,7 @@ internal sealed class TangemPayCardNavigation {
val cryptoBalance: SerializedBigDecimal,
val fiatBalance: SerializedBigDecimal,
val depositAddress: String,
val chainId: Int,
val cryptoCurrency: CryptoCurrency,
) : TangemPayCardNavigation()
@Serializable

View file

@ -1,6 +1,7 @@
package com.tangem.features.tangempay.entity
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
@ -18,7 +19,7 @@ internal sealed class TangemPayDetailsNavigation {
val cryptoBalance: SerializedBigDecimal,
val fiatBalance: SerializedBigDecimal,
val depositAddress: String,
val chainId: Int,
val cryptoCurrency: CryptoCurrency,
) : TangemPayDetailsNavigation()
@Serializable

View file

@ -55,7 +55,7 @@ internal class TangemPayDetailsStateFactory(
),
),
onAddCardClick = intents::onAddCardClick,
),
).takeIf { !isTangemPayDeactivated },
),
isBalanceHidden = false,
addFundsEnabled = true,

View file

@ -63,23 +63,23 @@ internal sealed interface DisplayNameState {
internal sealed class TangemPayDetailsBalanceBlockState {
abstract val actionButtons: ImmutableList<ActionButtonConfig>
abstract val cardsBlockState: CardsBlockState
abstract val cardsBlockState: CardsBlockState?
data class Loading(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val cardsBlockState: CardsBlockState,
override val cardsBlockState: CardsBlockState?,
) : TangemPayDetailsBalanceBlockState()
data class Content(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val cardsBlockState: CardsBlockState,
override val cardsBlockState: CardsBlockState?,
val fiatBalance: String,
val isBalanceFlickering: Boolean,
) : TangemPayDetailsBalanceBlockState()
data class Error(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val cardsBlockState: CardsBlockState,
override val cardsBlockState: CardsBlockState?,
) : TangemPayDetailsBalanceBlockState()
data class CardsBlockState(val cards: ImmutableList<Card>, val onAddCardClick: () -> Unit)

View file

@ -23,6 +23,8 @@ import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@ -44,6 +46,8 @@ internal class TangemPayCardLimitSetupModel @Inject constructor(
) : Model() {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
private val cardId: String = params.initialStatus.firstCard().id
private val userWalletId = params.initialStatus.userWalletId
private var currentAdminLimit: BigDecimal? = null
val uiState: StateFlow<TangemPayCardLimitSetupUM>
@ -70,15 +74,15 @@ internal class TangemPayCardLimitSetupModel @Inject constructor(
}
private fun observeCardState() {
paymentAccountStatusSupplier.invoke(params.userWalletId)
paymentAccountStatusSupplier.invoke(userWalletId)
.map { it.value }
.filterIsInstance<PaymentAccountStatusValue.Loaded>()
.filter { status ->
status.source == StatusSource.ACTUAL && status.findCardWithId(params.config.cardId) != null
status.source == StatusSource.ACTUAL && status.findCardWithId(cardId) != null
}
.withIndex()
.onEach { (index, status) ->
val card = status.requireCardWithId(params.config.cardId)
val card = status.requireCardWithId(cardId)
val currentLimit = card.limit?.actualCardLimit
?.takeIf { it.period == TangemPayCardLimitPeriod.DAY }
@ -131,8 +135,8 @@ internal class TangemPayCardLimitSetupModel @Inject constructor(
modelScope.launch {
uiState.update { it.copy(isSubmitButtonLoading = true) }
setTangemPayCardLimitUseCase(
cardId = params.config.cardId,
userWalletId = params.userWalletId,
cardId = cardId,
userWalletId = userWalletId,
amount = amount,
).fold(
ifLeft = {

View file

@ -6,9 +6,7 @@ import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.models.ReceiveAddressModel
import com.tangem.domain.models.ReceiveAddressModel.DisplayType
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.tangempay.components.TangemPayAddFundsComponent
import com.tangem.features.tangempay.entity.TangemPayAddFundsUM
import com.tangem.features.tangempay.model.transformers.TangemPayAddFundsUMConverter
@ -20,8 +18,6 @@ import javax.inject.Inject
internal class TangemPayAddFundsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory,
private val getUserWalletUseCase: GetUserWalletUseCase,
) : Model() {
private val params = paramsContainer.require<TangemPayAddFundsComponent.Params>()
@ -29,25 +25,19 @@ internal class TangemPayAddFundsModel @Inject constructor(
val uiState: TangemPayAddFundsUM = getInitialState()
private fun getInitialState(): TangemPayAddFundsUM {
val userWallet = getUserWalletUseCase(params.walletId).getOrNull()
val currency = userWallet?.let {
tangemPayCryptoCurrencyFactory.create(userWallet = userWallet, chainId = params.chainId).getOrNull()
}
val data = currency?.let {
TangemPayTopUpData(
currency = currency,
walletId = params.walletId,
cryptoBalance = params.cryptoBalance,
fiatBalance = params.fiatBalance,
depositAddress = params.depositAddress,
receiveAddress = listOf(
ReceiveAddressModel(
displayType = DisplayType.Default,
value = params.depositAddress,
),
val data = TangemPayTopUpData(
currency = params.cryptoCurrency,
walletId = params.walletId,
cryptoBalance = params.cryptoBalance,
fiatBalance = params.fiatBalance,
depositAddress = params.depositAddress,
receiveAddress = listOf(
ReceiveAddressModel(
displayType = DisplayType.Default,
value = params.depositAddress,
),
)
}
),
)
return TangemPayAddFundsUMConverter(listener = params.listener).convert(data)
}

View file

@ -57,10 +57,11 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
) : Model() {
private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require()
private val card = params.card
private val stateFactory = TangemPayCardDetailsBlockStateFactory(
cardNumberEnd = params.params.config.cardNumberEnd,
displayName = params.params.config.displayName,
cardNumberEnd = card.lastDigits,
displayName = card.displayName,
isEditingNameEnabled = params.isEditingNameEnabled,
onEditNameClick = ::startEditingDisplayName,
onReveal = ::revealCardDetails,
@ -74,7 +75,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
private val showCardDetailsTimerJobHolder = JobHolder()
init {
subscribeToCardNameChanges(cardId = params.params.config.cardId, userWalletId = params.params.userWalletId)
subscribeToCardNameChanges(cardId = card.id, userWalletId = params.userWalletId)
subscribeToCardFrozenState()
modelScope.launch {
cardDetailsEventListener.event.collectLatest { event ->
@ -105,7 +106,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
private fun subscribeToCardFrozenState() {
cardDetailsRepository
.cardFrozenState(params.params.config.cardId)
.cardFrozenState(card.id)
.onEach { uiState.update { state -> state.copy(cardFrozenState = it) } }
.launchIn(modelScope)
}
@ -116,7 +117,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor(
uiState.transformerUpdate(
transformer = DetailsRevealProgressStateTransformer(onClickHide = ::hideCardDetails),
)
cardDetailsRepository.revealCardDetails(params.params.userWalletId)
cardDetailsRepository.revealCardDetails(params.userWalletId)
.onRight { cardDetails ->
uiState.transformerUpdate(
transformer = DetailsRevealedStateTransformer(

View file

@ -41,6 +41,9 @@ import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.*
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.features.tangempay.utils.cryptoCurrency
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
@ -65,6 +68,9 @@ internal class TangemPayCardPageModel @Inject constructor(
) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener {
private val params: TangemPayCardPageComponent.Params = paramsContainer.require()
private val cardId: String = params.initialStatus.firstCard().id
private val userWalletId = params.initialStatus.userWalletId
private val cryptoCurrency = params.initialStatus.cryptoCurrency
private val addToWalletBannerJobHolder = JobHolder()
private val addFundsJobHolder = JobHolder()
@ -85,14 +91,14 @@ internal class TangemPayCardPageModel @Inject constructor(
analytics.send(TangemPayAnalyticsEvents.CardManagementScreenOpened())
fetchAddToWalletBanner()
paymentAccountStatusSupplier.invoke(params.userWalletId)
paymentAccountStatusSupplier.invoke(userWalletId)
.onEach { state ->
val status = state.value
if (status is PaymentAccountStatusValue.Loaded &&
status.source == StatusSource.ACTUAL &&
status.hasCardWithId(params.config.cardId)
status.hasCardWithId(cardId)
) {
val card = status.requireCardWithId(params.config.cardId)
val card = status.requireCardWithId(cardId)
val limit = card.limit?.actualCardLimit?.takeIf { it.period == TangemPayCardLimitPeriod.DAY }
val dailyLimitState = if (limit != null) {
TangemPayDailyLimitBlockState.Content(
@ -138,8 +144,8 @@ internal class TangemPayCardPageModel @Inject constructor(
} else {
bottomSheetNavigation.activate(
TangemPayCardNavigation.ViewPinCode(
userWalletId = params.userWalletId,
cardId = params.config.cardId,
userWalletId = userWalletId,
cardId = cardId,
),
)
}
@ -164,7 +170,7 @@ internal class TangemPayCardPageModel @Inject constructor(
onReissueOrderStatusReceived(order.orderStatus)
if (order.orderStatus != OrderStatus.CANCELED) {
modelScope.launch {
reissueCardRepository.storeReissueOrderId(params.config.cardId, order.orderId)
reissueCardRepository.storeReissueOrderId(cardId, order.orderId)
}
} else {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong)))
@ -178,7 +184,7 @@ internal class TangemPayCardPageModel @Inject constructor(
override fun onClickAddFunds() {
bottomSheetNavigation.dismiss()
modelScope.launch {
val balance = cardDetailsRepository.getCardBalance(params.userWalletId).getOrNull()
val balance = cardDetailsRepository.getCardBalance(userWalletId).getOrNull()
val depositAddress = balance?.depositAddress
if (balance == null || depositAddress == null) {
uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_error)))
@ -186,11 +192,11 @@ internal class TangemPayCardPageModel @Inject constructor(
}
bottomSheetNavigation.activate(
TangemPayCardNavigation.AddFunds(
walletId = params.userWalletId,
walletId = userWalletId,
fiatBalance = balance.fiatBalance,
cryptoBalance = balance.cryptoBalance,
depositAddress = depositAddress,
chainId = params.config.chainId,
cryptoCurrency = cryptoCurrency,
),
)
}.saveIn(addFundsJobHolder)
@ -233,8 +239,8 @@ internal class TangemPayCardPageModel @Inject constructor(
private fun freezeCard() {
modelScope.launch {
cardDetailsRepository.freezeCard(
userWalletId = params.userWalletId,
cardId = params.config.cardId,
userWalletId = userWalletId,
cardId = cardId,
).onLeft {
val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed))
uiMessageSender.send(message)
@ -252,8 +258,8 @@ internal class TangemPayCardPageModel @Inject constructor(
private fun unfreezeCard() {
modelScope.launch {
cardDetailsRepository.unfreezeCard(
userWalletId = params.userWalletId,
cardId = params.config.cardId,
userWalletId = userWalletId,
cardId = cardId,
).onLeft {
val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed))
uiMessageSender.send(message)
@ -270,7 +276,7 @@ internal class TangemPayCardPageModel @Inject constructor(
private fun fetchAddToWalletBanner() {
modelScope.launch {
val isDone = cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true
val isDone = cardDetailsRepository.isAddToWalletDone(userWalletId).getOrNull() == true
if (!isDone) {
uiState.update { state ->
state.copy(
@ -290,7 +296,7 @@ internal class TangemPayCardPageModel @Inject constructor(
private fun onClickCloseBanner() {
modelScope.launch {
cardDetailsRepository.setAddToWalletAsDone(params.userWalletId)
cardDetailsRepository.setAddToWalletAsDone(userWalletId)
uiState.update { it.copy(addToWalletBlockState = null) }
}.saveIn(addToWalletBannerJobHolder)
}

View file

@ -17,6 +17,7 @@ import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayChangePinUM
import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer
import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import com.tangem.utils.transformer.update
@ -55,7 +56,7 @@ internal class TangemPayChangePinModel @Inject constructor(
uiState.update { it.copy(submitButtonLoading = true) }
val result = try {
cardDetailsRepository.setPin(
userWalletId = params.userWalletId,
userWalletId = params.initialStatus.userWalletId,
pin = uiState.value.pinCode,
).getOrNull()
} catch (e: Exception) {

View file

@ -20,16 +20,15 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayTopUpData
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.features.tangempay.TangemPayConstants
import com.tangem.features.tangempay.components.AddFundsListener
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
@ -41,10 +40,7 @@ import com.tangem.features.tangempay.model.listener.CardDetailsEvent
import com.tangem.features.tangempay.model.listener.CardDetailsEventListener
import com.tangem.features.tangempay.model.transformers.*
import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute
import com.tangem.features.tangempay.utils.TangemPayDetailIntents
import com.tangem.features.tangempay.utils.TangemPayMessagesFactory
import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions
import com.tangem.features.tangempay.utils.TangemPayTxHistoryUpdateListener
import com.tangem.features.tangempay.utils.*
import com.tangem.features.tokendetails.ExpressTransactionsEvent
import com.tangem.features.tokendetails.ExpressTransactionsEventListener
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -74,27 +70,38 @@ internal class TangemPayDetailsModel @Inject constructor(
private val uiMessageSender: UiMessageSender,
private val cardDetailsEventListener: CardDetailsEventListener,
private val txHistoryUpdateListener: TangemPayTxHistoryUpdateListener,
private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory,
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
private val expressTransactionsEventListener: ExpressTransactionsEventListener,
) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener {
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
private val userWalletId = params.initialStatus.userWalletId
private val isTangemPayDeactivated = params.initialStatus.isDeactivated
private val loaded: PaymentAccountStatusValue.Loaded? =
params.initialStatus.value as? PaymentAccountStatusValue.Loaded
private val firstCard = loaded?.cards?.firstOrNull()
val cryptoCurrency: CryptoCurrency = params.initialStatus.cryptoCurrency
private val initialCardFrozenState: TangemPayCardFrozenState = when {
firstCard == null -> TangemPayCardFrozenState.Unfrozen
firstCard.isFrozen -> TangemPayCardFrozenState.Frozen
else -> TangemPayCardFrozenState.Unfrozen
}
private val stateFactory = TangemPayDetailsStateFactory(
onBack = router::pop,
onOpenMenu = ::onOpenMenu,
intents = this,
cardFrozenState = params.config.cardFrozenState,
cardFrozenState = initialCardFrozenState,
)
val uiState: StateFlow<TangemPayDetailsUM>
field = MutableStateFlow(
stateFactory.getInitialState(
isTangemPayDeactivated = params.config.isTangemPayDeactivated,
cardNumberEnd = params.config.cardNumberEnd,
isTangemPayDeactivated = isTangemPayDeactivated,
cardNumberEnd = firstCard?.lastDigits.orEmpty(),
),
)
@ -104,19 +111,14 @@ internal class TangemPayDetailsModel @Inject constructor(
private var balance: TangemPayCardBalance? = null
private val userWallet: UserWallet? = getUserWalletUseCase(params.userWalletId).getOrNull()
val cryptoCurrency: CryptoCurrency? = userWallet?.let { wallet ->
tangemPayCryptoCurrencyFactory.create(userWallet = wallet, chainId = params.config.chainId).getOrNull()
}
val bottomSheetNavigation: SlotNavigation<TangemPayDetailsNavigation> = SlotNavigation()
init {
analytics.send(TangemPayAnalyticsEvents.MainScreenOpened())
handleBalanceHiding()
fetchBalance()
if (!params.config.isTangemPayDeactivated) {
subscribeToCardFrozenState()
if (!isTangemPayDeactivated && firstCard != null) {
subscribeToCardFrozenState(firstCard.id)
fetchAddToWalletBanner()
}
}
@ -133,9 +135,9 @@ internal class TangemPayDetailsModel @Inject constructor(
}
}
private fun subscribeToCardFrozenState() {
private fun subscribeToCardFrozenState(cardId: String) {
cardDetailsRepository
.cardFrozenState(params.config.cardId)
.cardFrozenState(cardId)
.onEach { uiState.update(TangemPayFreezeUnfreezeStateTransformer(cardFrozenState = it)) }
.launchIn(modelScope)
}
@ -149,11 +151,11 @@ internal class TangemPayDetailsModel @Inject constructor(
} else {
bottomSheetNavigation.activate(
TangemPayDetailsNavigation.AddFunds(
walletId = params.userWalletId,
walletId = userWalletId,
fiatBalance = currentBalance.availableForWithdrawal,
cryptoBalance = currentBalance.availableForWithdrawal,
depositAddress = depositAddress,
chainId = params.config.chainId,
cryptoCurrency = cryptoCurrency,
),
)
}
@ -165,31 +167,18 @@ internal class TangemPayDetailsModel @Inject constructor(
val depositAddress = currentBalance?.depositAddress
if (currentBalance == null || depositAddress == null) {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
} else {
val userWallet = userWallet ?: getUserWalletUseCase(params.userWalletId).getOrNull()
if (userWallet == null) {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
return
}
modelScope.launch {
val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWalletId)
if (hasActiveWithdrawal) {
showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress)
} else {
modelScope.launch {
val hasActiveWithdrawal = tangemPayWithdrawRepository.hasWithdrawOrder(userWallet = userWallet)
if (hasActiveWithdrawal) {
showBottomSheetError(TangemPayDetailsErrorType.WithdrawInProgress)
} else {
val currency = cryptoCurrency ?: tangemPayCryptoCurrencyFactory.create(
userWallet = userWallet,
chainId = params.config.chainId,
).getOrNull()
if (currency != null) {
uiMessageSender.send(
message = TangemPayMessagesFactory.createWithdrawWarning(
onGotItClick = { onConfirmWithdrawal(currency, currentBalance, depositAddress) },
),
)
} else {
showBottomSheetError(TangemPayDetailsErrorType.Withdraw)
}
}
}
uiMessageSender.send(
message = TangemPayMessagesFactory.createWithdrawWarning(
onGotItClick = { onConfirmWithdrawal(cryptoCurrency, currentBalance, depositAddress) },
),
)
}
}
}
@ -202,7 +191,7 @@ internal class TangemPayDetailsModel @Inject constructor(
router.push(
AppRoute.Swap(
cryptoCurrency = currency,
userWalletId = params.userWalletId,
userWalletId = userWalletId,
screenSource = AnalyticsParam.ScreensSources.TangemPay.value,
currencyPosition = AppRoute.Swap.CurrencyPosition.FROM,
tangemPayInput = AppRoute.Swap.TangemPayInput(
@ -218,25 +207,19 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun fetchBalance(): Job {
return modelScope.launch {
val result = try {
cardDetailsRepository.getCardBalance(params.userWalletId).onRight { balance = it }
cardDetailsRepository.getCardBalance(userWalletId).onRight { balance = it }
} catch (e: Exception) {
TangemLogger.e("Error", e)
return@launch
}
uiState.update(
transformer = DetailsBalanceTransformer(
balance = result,
userWallet = getUserWalletUseCase(params.userWalletId).getOrNull(),
cryptoCurrencyFactory = tangemPayCryptoCurrencyFactory,
),
)
uiState.update(transformer = DetailsBalanceTransformer(balance = result))
}.saveIn(fetchBalanceJobHolder)
}
private fun fetchAddToWalletBanner() {
modelScope.launch {
val isDone = try {
cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true
cardDetailsRepository.isAddToWalletDone(userWalletId).getOrNull() == true
} catch (e: Exception) {
TangemLogger.e("Error", e)
return@launch
@ -259,11 +242,12 @@ internal class TangemPayDetailsModel @Inject constructor(
override fun onContactSupportClicked() {
analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay))
val customerId = loaded?.customerId ?: return
modelScope.launch {
sendFeedbackEmailUseCase.invoke(
type = FeedbackEmailType.Visa.FeatureIsBeta(
walletMetaInfo = WalletMetaInfo(userWalletId = params.userWalletId),
customerId = params.config.customerId,
walletMetaInfo = WalletMetaInfo(userWalletId = userWalletId),
customerId = customerId,
),
)
}
@ -288,7 +272,7 @@ internal class TangemPayDetailsModel @Inject constructor(
private fun onClickCloseAddToWalletBlock() {
modelScope.launch {
try {
cardDetailsRepository.setAddToWalletAsDone(params.userWalletId)
cardDetailsRepository.setAddToWalletAsDone(userWalletId)
} catch (e: Exception) {
TangemLogger.e("Error", e)
}

View file

@ -21,6 +21,8 @@ import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM
import com.tangem.features.tangempay.utils.firstCard
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -39,7 +41,8 @@ internal class TangemPayEditDisplayNameModel @Inject constructor(
private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require()
private val originalDisplayName = params.config.displayName?.value.orEmpty()
private val card = params.initialStatus.firstCard()
private val originalDisplayName = card.displayName?.value.orEmpty()
val uiState: StateFlow<TangemPayEditDisplayNameUM>
field = MutableStateFlow(
@ -56,7 +59,7 @@ internal class TangemPayEditDisplayNameModel @Inject constructor(
)
init {
subscribeToCardNameChanges(params.config.cardId, params.userWalletId)
subscribeToCardNameChanges(card.id, params.initialStatus.userWalletId)
}
private fun subscribeToCardNameChanges(cardId: String, userWalletId: UserWalletId) {
@ -100,8 +103,8 @@ internal class TangemPayEditDisplayNameModel @Inject constructor(
modelScope.launch {
uiState.update { it.copy(isLoading = true) }
updateCardNameUseCase(
cardId = params.config.cardId,
userWalletId = params.userWalletId,
cardId = card.id,
userWalletId = params.initialStatus.userWalletId,
displayName = cardDisplayName,
).onRight {
router.pop()

View file

@ -4,8 +4,6 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
@ -15,8 +13,6 @@ import java.util.Currency
internal class DetailsBalanceTransformer(
private val balance: Either<UniversalError, TangemPayCardBalance>,
private val cryptoCurrencyFactory: TangemPayCryptoCurrencyFactory,
private val userWallet: UserWallet?,
) : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
@ -28,22 +24,12 @@ internal class DetailsBalanceTransformer(
)
}
is Either.Right<TangemPayCardBalance> -> {
val cryptoCurrency = userWallet?.let {
cryptoCurrencyFactory.create(userWallet, balance.value.chainId).getOrNull()
}
if (cryptoCurrency == null) {
TangemPayDetailsBalanceBlockState.Error(
actionButtons = persistentListOf(),
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
)
} else {
TangemPayDetailsBalanceBlockState.Content(
isBalanceFlickering = false,
fiatBalance = getFiatBalanceText(balance.value),
actionButtons = prevState.balanceBlockState.actionButtons,
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
)
}
TangemPayDetailsBalanceBlockState.Content(
isBalanceFlickering = false,
fiatBalance = getFiatBalanceText(balance.value),
actionButtons = prevState.balanceBlockState.actionButtons,
cardsBlockState = prevState.balanceBlockState.cardsBlockState,
)
}
}
return prevState.copy(balanceBlockState = balance)

View file

@ -237,12 +237,14 @@ private fun TangemPayDetailsBalanceBlock(
state = state,
isBalanceHidden = isBalanceHidden,
)
CardsBlockRow(
modifier = Modifier
.wrapContentSize()
.padding(horizontal = 12.dp, vertical = 8.dp),
cardsBlockState = state.cardsBlockState,
)
state.cardsBlockState?.let { cardsBlockState ->
CardsBlockRow(
modifier = Modifier
.wrapContentSize()
.padding(horizontal = 12.dp, vertical = 8.dp),
cardsBlockState = cardsBlockState,
)
}
if (state.actionButtons.isNotEmpty()) {
HorizontalActionChips(
modifier = Modifier.padding(top = 12.dp),

View file

@ -0,0 +1,26 @@
package com.tangem.features.tangempay.utils
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.wallet.UserWalletId
internal val AccountStatus.Payment.userWalletId: UserWalletId
get() = account.userWalletId
internal val AccountStatus.Payment.cryptoCurrency: CryptoCurrency.Token
get() = when (val v = value) {
is PaymentAccountStatusValue.Loaded -> v.cryptoCurrency
is PaymentAccountStatusValue.Deactivated -> v.cryptoCurrency
else -> error("TangemPayDetails opened with unsupported status: $v")
}
internal val AccountStatus.Payment.isDeactivated: Boolean
get() = value is PaymentAccountStatusValue.Deactivated
internal fun AccountStatus.Payment.requireLoaded(): PaymentAccountStatusValue.Loaded =
value as? PaymentAccountStatusValue.Loaded
?: error("Card-detail subflow requires Loaded status, got ${value::class.simpleName}")
internal fun AccountStatus.Payment.firstCard(): TangemPayCard = requireLoaded().cards.first()

View file

@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.pay.TangemPayCard
@ -12,10 +13,8 @@ import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
@ -40,20 +39,24 @@ internal class TangemPayCardLimitSetupModelTest {
private val setLimitUseCase: SetTangemPayCardLimitUseCase = mockk(relaxed = true)
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val params = TangemPayDetailsContainerComponent.Params(
userWalletId = userWalletId,
config = TangemPayDetailsConfig(
customerId = "customer1",
cardId = cardId,
isPinSet = false,
cardFrozenState = TangemPayCardFrozenState.Unfrozen,
cardNumberEnd = "1234",
chainId = 1,
isTangemPayDeactivated = false,
displayName = null,
),
private val initialCard = TangemPayCard(
id = cardId,
hasPinCode = false,
displayName = null,
isFrozen = false,
lastDigits = "1234",
limit = null,
)
private val initialStatus: AccountStatus.Payment = AccountStatus.Payment(
account = Account.Payment(userWalletId = userWalletId),
value = mockk<PaymentAccountStatusValue.Loaded>(relaxed = true) {
every { cards } returns listOf(initialCard)
},
)
private val params = TangemPayDetailsContainerComponent.Params(initialStatus = initialStatus)
private fun createModel(
adminLimit: BigDecimal? = BigDecimal("1000"),
): TangemPayCardLimitSetupModel {

View file

@ -9,6 +9,31 @@ internal open class TokenDetailsAnalyticsEvent(
params: Map<String, String> = mapOf(),
) : AnalyticsEvent(category = "Token", event, params) {
class DynamicAddressesScreenOpened(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Dynamic Addresses Screen Opened",
params = currency.toAnalyticsParams(),
)
class ButtonEnableDynamicAddresses(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Button - Enable Dynamic Addresses",
params = currency.toAnalyticsParams(),
)
class DynamicAddressesEnabled(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Dynamic Addresses Enabled",
params = currency.toAnalyticsParams(),
)
class ButtonDisableDynamicAddresses(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Button - Disable Dynamic Addresses",
params = currency.toAnalyticsParams(),
)
class DynamicAddressesDisabled(currency: CryptoCurrency) : TokenDetailsAnalyticsEvent(
event = "Dynamic Addresses Disabled",
params = currency.toAnalyticsParams(),
)
open class Notice(
event: String,
params: Map<String, String> = mapOf(),
@ -19,14 +44,40 @@ internal open class TokenDetailsAnalyticsEvent(
params = currency.toAnalyticsParams(),
)
class NotEnoughFee(currency: CryptoCurrency) : Notice(
class NotEnoughFee(currency: CryptoCurrency, source: Source) : Notice(
event = "Not Enough Fee",
params = currency.toAnalyticsParams(),
)
params = currency.toAnalyticsParams() + ("Source" to source.value),
) {
enum class Source(val value: String) {
DetailedScreen("Detailed Screen"),
DynamicAddresses("Dynamic Addresses"),
}
}
class Reveal(currency: CryptoCurrency) : Notice(
event = "Reveal Transaction",
params = currency.toAnalyticsParams(),
)
class DynamicAddressesUnavailable(currency: CryptoCurrency) : Notice(
event = "Dynamic Addresses Unavailable",
params = currency.toAnalyticsParams(),
)
class AdditionalAddressesFound(currency: CryptoCurrency) : Notice(
event = "Additional Addresses Found",
params = currency.toAnalyticsParams(),
)
}
open class Error(
event: String,
params: Map<String, String> = emptyMap(),
) : TokenDetailsAnalyticsEvent(event = "Error - $event", params) {
class DynamicAddressesUnavailable(currency: CryptoCurrency) : Error(
event = "Dynamic Addresses Unavailable",
params = currency.toAnalyticsParams(),
)
}
}

View file

@ -37,6 +37,7 @@ internal class TokenDetailsNotificationsAnalyticsSender(
is TokenDetailsNotification.NetworkFeeWithBuyButton,
-> TokenDetailsAnalyticsEvent.Notice.NotEnoughFee(
currency = cryptoCurrency,
source = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee.Source.DetailedScreen,
)
is TokenDetailsNotification.SwapPromo -> PromoAnalyticsEvent.NoticePromotionBanner(
program = PromoAnalyticsEvent.Program.Empty, // Use it on new promo action
@ -49,6 +50,10 @@ internal class TokenDetailsNotificationsAnalyticsSender(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
)
is TokenDetailsNotification.DynamicAddressesFundsFound ->
TokenDetailsAnalyticsEvent.Notice.AdditionalAddressesFound(
currency = cryptoCurrency,
)
is TokenDetailsNotification.NetworksUnreachable,
is TokenDetailsNotification.ExistentialDeposit,
is TokenDetailsNotification.NetworksNoAccount,

View file

@ -1,6 +1,8 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.ResettableOneTimeEventSender
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.res.R
@ -23,6 +25,7 @@ import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsAnalyticsEvent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
@ -37,7 +40,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class DynamicAddressesDelegate @AssistedInject constructor(
private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase,
private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase,
@ -47,6 +50,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val userWallet: UserWallet,
@ -68,17 +72,20 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
)
val bottomSheetConfig: StateFlow<DynamicAddressesBottomSheetConfig> = _bottomSheetConfig.asStateFlow()
private val resettableOneTimeEventSender = ResettableOneTimeEventSender(analyticsEventHandler)
// region Entry point
fun onDynamicAddressesClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened(currency))
coroutineScope.launch(dispatchers.main) {
val status = dynamicAddressesRepository.getStatus(userWalletId, network).first()
val status = dynamicAddressesRepository.getStatus(userWalletId, currency.network).first()
when (status) {
DynamicAddressesStatus.ENABLED,
DynamicAddressesStatus.ENABLED_REQUIRES_SETUP,
-> onDisableFlow(network)
DynamicAddressesStatus.DISABLED -> onEnableFlow(network)
-> onDisableFlow(currency.network)
DynamicAddressesStatus.DISABLED -> onEnableFlow(currency.network)
}
}
}
@ -90,6 +97,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
private suspend fun onEnableFlow(network: Network) {
val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)
if (hasConflicts) {
cryptoCurrencyStatusProvider()?.currency?.let {
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable(it))
}
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
onDismissClick = dismissBottomSheet,
)
@ -106,7 +116,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun onEnableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
val network = currency.network
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonEnableDynamicAddresses(currency))
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
@ -120,6 +132,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
dismissBottomSheet()
} else {
TangemLogger.e("Failed to get XPUB: ${error.message}")
analyticsEventHandler.send(
TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(currency),
)
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
@ -131,21 +146,24 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
enableDynamicAddressesUseCase(userWalletId, network, xpub).fold(
ifLeft = { error ->
when (error) {
is EnableDynamicAddressesError.ConflictingCustomTokens -> {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
analyticsEventHandler.send(
TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(currency),
)
_bottomSheetConfig.value = when (error) {
is EnableDynamicAddressesError.ConflictingCustomTokens ->
DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
onDismissClick = dismissBottomSheet,
)
}
is EnableDynamicAddressesError.ServiceError -> {
TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
}
},
ifRight = {
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesEnabled(currency))
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
@ -190,10 +208,13 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun onSimpleDisableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
val network = currency.network
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses(currency))
coroutineScope.launch(dispatchers.main) {
runSuspendCatching { dynamicAddressesRepository.disable(userWalletId, network) }
.onSuccess {
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesDisabled(currency))
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
@ -210,6 +231,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun showDisableSheetAndLoadFee() {
resettableOneTimeEventSender.reset(NOT_ENOUGH_FEE_EVENT_KEY)
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading,
onDisableClick = ::onDisableClick,
@ -245,6 +267,13 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
cryptoCurrency = currency,
).fold(
ifLeft = {
resettableOneTimeEventSender.sendEventOnce(
key = NOT_ENOUGH_FEE_EVENT_KEY,
event = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee(
currency = currency,
source = TokenDetailsAnalyticsEvent.Notice.NotEnoughFee.Source.DynamicAddresses,
),
)
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error,
)
@ -282,7 +311,9 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
}
private fun onDisableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
val currency = cryptoCurrencyStatusProvider()?.currency ?: return
val network = currency.network
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses(currency))
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
isSending = true,
@ -320,6 +351,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
} catch (e: Exception) {
TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}")
}
analyticsEventHandler.send(TokenDetailsAnalyticsEvent.DynamicAddressesDisabled(currency))
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
@ -359,4 +391,8 @@ internal class DynamicAddressesDelegate @AssistedInject constructor(
@Assisted("onDynamicAddressesStateChanged") onDynamicAddressesStateChanged: () -> Unit,
): DynamicAddressesDelegate
}
private companion object {
const val NOT_ENOUGH_FEE_EVENT_KEY = "DynamicAddressesNotEnoughFee"
}
}

View file

@ -13,6 +13,7 @@ import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.common.TangemBlogUrlBuilder
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
@ -176,6 +177,7 @@ internal class TokenDetailsModel @Inject constructor(
private val signCloreMessageUseCase: SignCloreMessageUseCase,
private val isXpubSupportedUseCase: IsXpubSupportedUseCase,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesDelegateFactory: DynamicAddressesDelegate.Factory,
private val dialogFactory: TokenDetailsDialogFactory,
private val userWalletsListRepository: UserWalletsListRepository,
@ -1258,7 +1260,7 @@ internal class TokenDetailsModel @Inject constructor(
return TokenDetailsBottomSheetConfig.Receive(receiveConfig)
}
private fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
private suspend fun sendOneTimeBalanceLoadedAnalyticsEvent(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
if (isBalanceLoadedEventSent || cryptoCurrencyStatus == null) return
val tokenBalance = when (val value = cryptoCurrencyStatus.value) {
@ -1290,11 +1292,19 @@ internal class TokenDetailsModel @Inject constructor(
blockchain = cryptoCurrency.network.name,
token = cryptoCurrency.symbol,
tokenBalance = tokenBalance,
isDynamicAddress = getIsDynamicAddressParam(),
),
)
isBalanceLoadedEventSent = true
}
private suspend fun getIsDynamicAddressParam(): Boolean? {
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(cryptoCurrency.network.rawId)) return null
return dynamicAddressesRepository
.isDynamicAddressesEnabledForNetwork(userWalletId, cryptoCurrency.network.id)
.first()
}
private suspend fun needShowYieldSupplyWarning(): Boolean {
return needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)
}

View file

@ -0,0 +1,137 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.analytics
import com.google.common.truth.Truth.assertThat
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.collections.immutable.toPersistentList
import org.junit.jupiter.api.Test
internal class TokenDetailsNotificationsAnalyticsSenderTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val network: Network = mockk(relaxed = true) {
every { name } returns "Ethereum"
}
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
every { symbol } returns "ETH"
every { this@mockk.network } returns this@TokenDetailsNotificationsAnalyticsSenderTest.network
}
private val sender = TokenDetailsNotificationsAnalyticsSender(
cryptoCurrency = cryptoCurrency,
analyticsEventHandler = analyticsEventHandler,
)
@Test
fun `GIVEN NetworkFee notification WHEN send THEN NotEnoughFee event with DetailedScreen source is sent`() {
// GIVEN
val notification = mockk<TokenDetailsNotification.NetworkFee>()
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
val eventSlot = slot<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.NotEnoughFee
assertThat(event.category).isEqualTo("Token")
assertThat(event.event).isEqualTo("Notice - Not Enough Fee")
assertThat(event.params).containsEntry("Token", "ETH")
assertThat(event.params).containsEntry("Blockchain", "Ethereum")
assertThat(event.params).containsEntry("Source", "Detailed Screen")
}
@Test
fun `GIVEN NetworkFeeWithBuyButton notification WHEN send THEN NotEnoughFee event with DetailedScreen source is sent`() {
// GIVEN
val notification = mockk<TokenDetailsNotification.NetworkFeeWithBuyButton>()
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
val eventSlot = slot<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.NotEnoughFee
assertThat(event.params).containsEntry("Source", "Detailed Screen")
}
@Test
fun `GIVEN DynamicAddressesFundsFound notification WHEN send THEN AdditionalAddressesFound event is sent`() {
// GIVEN
val notification = TokenDetailsNotification.DynamicAddressesFundsFound(onLearnMoreClick = {})
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
val eventSlot = slot<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
verify(exactly = 1) { analyticsEventHandler.send(any()) }
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.Notice.AdditionalAddressesFound
assertThat(event.category).isEqualTo("Token")
assertThat(event.event).isEqualTo("Notice - Additional Addresses Found")
assertThat(event.params).containsEntry("Token", "ETH")
assertThat(event.params).containsEntry("Blockchain", "Ethereum")
}
@Test
fun `GIVEN empty new notifications WHEN send THEN no event is sent`() {
// GIVEN
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = emptyList())
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN pullToRefresh is refreshing WHEN send THEN no event is sent`() {
// GIVEN
val notification = TokenDetailsNotification.DynamicAddressesFundsFound(onLearnMoreClick = {})
val displayedState = createState(notifications = emptyList(), isRefreshing = true)
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN notification without matching event WHEN send THEN no event is sent`() {
// GIVEN: NetworksUnreachable is an unmapped notification (returns null in getEvent)
val notification = TokenDetailsNotification.NetworksUnreachable
val displayedState = createState(notifications = emptyList(), isRefreshing = false)
// WHEN
sender.send(displayedUiState = displayedState, newNotifications = listOf(notification))
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
private fun createState(
notifications: List<TokenDetailsNotification>,
isRefreshing: Boolean,
): TokenDetailsState {
return mockk(relaxed = true) {
every { this@mockk.notifications } returns notifications.toPersistentList()
every { pullToRefreshConfig.isRefreshing } returns isRefreshing
}
}
}

View file

@ -0,0 +1,441 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.TransactionData
import com.tangem.common.core.TangemSdkError
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
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.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsAnalyticsEvent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.math.BigDecimal
private const val TEST_XPUB = "xpub-test-value"
private const val TOKEN_SYMBOL = "ETH"
private const val BLOCKCHAIN_NAME = "Ethereum"
private const val TEST_ADDRESS = "0xTestAddress"
@OptIn(ExperimentalCoroutinesApi::class)
internal class DynamicAddressesDelegateTest {
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase = mockk()
private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase = mockk()
private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase = mockk()
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
private val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true)
private val getDerivedXpubUseCase: GetDerivedXpubUseCase = mockk()
private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true)
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase = mockk()
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
private val network: Network = mockk(relaxed = true) {
every { name } returns BLOCKCHAIN_NAME
}
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
every { symbol } returns TOKEN_SYMBOL
every { this@mockk.network } returns this@DynamicAddressesDelegateTest.network
}
private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) {
every { currency } returns cryptoCurrency
}
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val userWallet: UserWallet = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private val showBottomSheet: () -> Unit = mockk(relaxed = true)
private val dismissBottomSheet: () -> Unit = mockk(relaxed = true)
private val onDynamicAddressesStateChanged: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN currency is available WHEN onDynamicAddressesClick THEN DynamicAddressesScreenOpened event is sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
val eventSlot = slot<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(eventSlot)) } returns Unit
// WHEN
delegate.onDynamicAddressesClick()
// THEN
val event = eventSlot.captured as TokenDetailsAnalyticsEvent.DynamicAddressesScreenOpened
assertThat(event.category).isEqualTo("Token")
assertThat(event.event).isEqualTo("Dynamic Addresses Screen Opened")
assertThat(event.params).containsEntry("Token", TOKEN_SYMBOL)
assertThat(event.params).containsEntry("Blockchain", BLOCKCHAIN_NAME)
}
@Test
fun `GIVEN no currency WHEN onDynamicAddressesClick THEN no event is sent`() = runTest {
// GIVEN
val delegate = createDelegate(cryptoCurrencyStatus = null)
// WHEN
delegate.onDynamicAddressesClick()
// THEN
verify(exactly = 0) { analyticsEventHandler.send(any()) }
}
@Test
fun `GIVEN DISABLED status AND conflicts WHEN onDynamicAddressesClick THEN Notice DynamicAddressesUnavailable is sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns true
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
// WHEN
delegate.onDynamicAddressesClick()
// THEN
verify {
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable> {
it.event == "Notice - Dynamic Addresses Unavailable" &&
it.params["Token"] == TOKEN_SYMBOL &&
it.params["Blockchain"] == BLOCKCHAIN_NAME
},
)
}
}
@Test
fun `GIVEN DISABLED status WHEN enable button clicked AND enable succeeds THEN ButtonEnable and DynamicAddressesEnabled are sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns TEST_XPUB.right()
coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns Unit.right()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
// THEN
verify {
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.ButtonEnableDynamicAddresses> {
it.event == "Button - Enable Dynamic Addresses" &&
it.params["Token"] == TOKEN_SYMBOL
},
)
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.DynamicAddressesEnabled> {
it.event == "Dynamic Addresses Enabled" &&
it.params["Token"] == TOKEN_SYMBOL
},
)
}
coVerify { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) }
}
@Test
fun `GIVEN DISABLED status WHEN enable button clicked AND xpub retrieval fails THEN Error DynamicAddressesUnavailable is sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns
IllegalStateException("xpub fail").left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
// THEN
verify {
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable> {
it.event == "Error - Dynamic Addresses Unavailable" &&
it.params["Token"] == TOKEN_SYMBOL
},
)
}
}
@Test
fun `GIVEN DISABLED status WHEN enable button clicked AND user cancels xpub derivation THEN Error event is NOT sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns
TangemSdkError.UserCancelled().left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
// THEN
verify(exactly = 0) {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable>())
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.DynamicAddressesEnabled>())
}
}
@Test
fun `GIVEN DISABLED status WHEN enable useCase fails THEN Error DynamicAddressesUnavailable is sent`() = runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.DISABLED)
coEvery { dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) } returns false
coEvery { getDerivedXpubUseCase(userWalletId, network) } returns TEST_XPUB
coEvery { getExtendedPublicKeyUseCase(userWalletId, network) } returns TEST_XPUB.right()
coEvery { enableDynamicAddressesUseCase(userWalletId, network, TEST_XPUB) } returns
EnableDynamicAddressesError.ServiceError(RuntimeException("boom")).left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.Enable).onEnableClick()
// THEN
verify {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable>())
}
verify(exactly = 0) {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.DynamicAddressesEnabled>())
}
}
@Test
fun `GIVEN ENABLED status AND no consolidation WHEN simple disable clicked THEN ButtonDisable and DynamicAddressesDisabled are sent`() =
runTest {
// GIVEN
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.ENABLED)
coEvery { disableDynamicAddressesUseCase(userWalletId, network) } returns false.right()
coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation)
.onDisableClick()
// THEN
verify {
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses> {
it.event == "Button - Disable Dynamic Addresses"
},
)
analyticsEventHandler.send(
match<TokenDetailsAnalyticsEvent.DynamicAddressesDisabled> {
it.event == "Dynamic Addresses Disabled"
},
)
}
}
@Test
fun `GIVEN consolidation required AND fee fails WHEN load fee THEN NotEnoughFee with DynamicAddresses source is sent`() =
runTest {
// GIVEN: consolidation required, status provides balance and address, fee load fails
setupConsolidationFlow()
coEvery {
getFeeUseCase(any<BigDecimal>(), any<String>(), userWallet, cryptoCurrency)
} returns mockk<GetFeeError>(relaxed = true).left()
val events = mutableListOf<AnalyticsEvent>()
every { analyticsEventHandler.send(capture(events)) } returns Unit
// WHEN
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// THEN
val notEnoughFee = events
.filterIsInstance<TokenDetailsAnalyticsEvent.Notice.NotEnoughFee>()
.single()
assertThat(notEnoughFee.event).isEqualTo("Notice - Not Enough Fee")
assertThat(notEnoughFee.params).containsEntry("Source", "Dynamic Addresses")
assertThat(notEnoughFee.params).containsEntry("Token", TOKEN_SYMBOL)
assertThat(notEnoughFee.params).containsEntry("Blockchain", BLOCKCHAIN_NAME)
}
@Test
fun `GIVEN consolidation required AND fee fails WHEN load fee retried THEN NotEnoughFee is sent only once`() =
runTest {
// GIVEN
setupConsolidationFlow()
coEvery {
getFeeUseCase(any<BigDecimal>(), any<String>(), userWallet, cryptoCurrency)
} returns mockk<GetFeeError>(relaxed = true).left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
// WHEN: initial load + refresh
delegate.onDynamicAddressesClick()
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
.onRefreshFee()
// THEN: one-time event sender collapses repeated errors
verify(exactly = 1) {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.Notice.NotEnoughFee>())
}
}
@Test
fun `GIVEN consolidation flow WHEN disable clicked AND tx succeeds THEN ButtonDisable and DynamicAddressesDisabled are sent`() =
runTest {
// GIVEN
setupConsolidationFlow()
coEvery {
getFeeUseCase(any<BigDecimal>(), any<String>(), userWallet, cryptoCurrency)
} returns mockk<GetFeeError>(relaxed = true).left()
val txData = mockk<TransactionData>(relaxed = true)
coEvery { createConsolidationTransactionUseCase(userWalletId, network) } returns txData.right()
coEvery { sendTransactionUseCase(txData, userWallet, network) } returns "tx-hash".right()
coEvery { dynamicAddressesRepository.disable(userWalletId, network) } returns Unit
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
.onDisableClick()
// THEN
verify {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses>())
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.DynamicAddressesDisabled>())
}
coVerify { sendTransactionUseCase(txData, userWallet, network) }
}
@Test
fun `GIVEN consolidation flow WHEN disable clicked AND tx cancelled by user THEN DynamicAddressesDisabled is NOT sent`() =
runTest {
// GIVEN
setupConsolidationFlow()
coEvery {
getFeeUseCase(any<BigDecimal>(), any<String>(), userWallet, cryptoCurrency)
} returns mockk<GetFeeError>(relaxed = true).left()
val txData = mockk<TransactionData>(relaxed = true)
coEvery { createConsolidationTransactionUseCase(userWalletId, network) } returns txData.right()
coEvery { sendTransactionUseCase(txData, userWallet, network) } returns
SendTransactionError.UserCancelledError.left()
val delegate = createDelegate(cryptoCurrencyStatus = cryptoCurrencyStatus)
delegate.onDynamicAddressesClick()
// WHEN
(delegate.bottomSheetConfig.value as DynamicAddressesBottomSheetConfig.DisableWithConsolidation)
.onDisableClick()
// THEN: ButtonDisable is sent on click, but success event is not
verify { analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.ButtonDisableDynamicAddresses>()) }
verify(exactly = 0) {
analyticsEventHandler.send(ofType<TokenDetailsAnalyticsEvent.DynamicAddressesDisabled>())
}
}
@Test
fun `GIVEN Notice category event WHEN sent THEN id starts with Token category`() {
// GIVEN
val notice = TokenDetailsAnalyticsEvent.Notice.DynamicAddressesUnavailable(cryptoCurrency)
val error = TokenDetailsAnalyticsEvent.Error.DynamicAddressesUnavailable(cryptoCurrency)
// THEN
assertThat(notice.category).isEqualTo("Token")
assertThat(notice.event).isEqualTo("Notice - Dynamic Addresses Unavailable")
assertThat(error.category).isEqualTo("Token")
assertThat(error.event).isEqualTo("Error - Dynamic Addresses Unavailable")
}
private fun setupConsolidationFlow() {
every { dynamicAddressesRepository.getStatus(userWalletId, network) } returns
flowOf(DynamicAddressesStatus.ENABLED)
coEvery { disableDynamicAddressesUseCase(userWalletId, network) } returns true.right()
val address = NetworkAddress.Address(value = TEST_ADDRESS, type = NetworkAddress.Address.Type.Primary)
every { cryptoCurrencyStatus.value } returns mockk(relaxed = true) {
every { amount } returns BigDecimal.ONE
every { fiatRate } returns BigDecimal.ONE
every { networkAddress } returns NetworkAddress.Single(defaultAddress = address)
}
}
private fun createDelegate(cryptoCurrencyStatus: CryptoCurrencyStatus?): DynamicAddressesDelegate {
val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
return DynamicAddressesDelegate(
enableDynamicAddressesUseCase = enableDynamicAddressesUseCase,
disableDynamicAddressesUseCase = disableDynamicAddressesUseCase,
createConsolidationTransactionUseCase = createConsolidationTransactionUseCase,
getFeeUseCase = getFeeUseCase,
sendTransactionUseCase = sendTransactionUseCase,
getDerivedXpubUseCase = getDerivedXpubUseCase,
dynamicAddressesRepository = dynamicAddressesRepository,
getExtendedPublicKeyUseCase = getExtendedPublicKeyUseCase,
analyticsEventHandler = analyticsEventHandler,
uiMessageSender = uiMessageSender,
dispatchers = TestingCoroutineDispatcherProvider(),
userWallet = userWallet,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = Provider { mockk<AppCurrency>(relaxed = true) },
coroutineScope = scope,
showBottomSheet = showBottomSheet,
dismissBottomSheet = dismissBottomSheet,
onDynamicAddressesStateChanged = onDynamicAddressesStateChanged,
)
}
}

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

@ -18,9 +18,9 @@ import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.TangemPayEntryPoint
@ -40,7 +40,7 @@ internal interface TangemPayIntents {
fun onRefreshPayToken(userWallet: UserWallet)
fun openDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig)
fun openDetails(status: AccountStatus.Payment)
fun onKycProgressClicked(userWalletId: UserWalletId)
@ -110,11 +110,8 @@ internal class TangemPayClickIntentsImplementor @Inject constructor(
}
}
override fun openDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig) {
router.openTangemPayDetails(
userWalletId = userWalletId,
config = config,
)
override fun openDetails(status: AccountStatus.Payment) {
router.openTangemPayDetails(status = status)
}
override fun onKycProgressClicked(userWalletId: UserWalletId) {

View file

@ -12,13 +12,13 @@ import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent
@ -142,8 +142,8 @@ internal class DefaultWalletRouter @Inject constructor(
router.push(route = AppRoute.TangemPayOnboarding(mode = mode))
}
override fun openTangemPayDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig) {
router.push(AppRoute.TangemPayDetails(userWalletId = userWalletId, config = config))
override fun openTangemPayDetails(status: AccountStatus.Payment) {
router.push(AppRoute.TangemPayDetails(status = status))
}
override fun openYieldSupplyBottomSheet(

View file

@ -7,13 +7,13 @@ import com.tangem.common.routing.AppRoute
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent
@ -82,7 +82,7 @@ internal interface InnerWalletRouter {
fun openTangemPayOnboarding(mode: AppRoute.TangemPayOnboarding.Mode)
fun openTangemPayDetails(userWalletId: UserWalletId, config: TangemPayDetailsConfig)
fun openTangemPayDetails(status: AccountStatus.Payment)
/** Open BS abput yield supply active and all money deposited in AAVE */
fun openYieldSupplyBottomSheet(

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,
),
)
}
}

Some files were not shown because too many files have changed in this diff Show more