Updated on 2026-08-14
This commit is contained in:
commit
24ef1d4ed9
120 changed files with 6250 additions and 675 deletions
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ internal class CreateWalletStartModelTest {
|
|||
verify {
|
||||
router.push(
|
||||
route = AppRoute.CreateMobileWallet(
|
||||
source = AnalyticsParam.ScreensSources.CreateWalletIntro.value,
|
||||
source = AnalyticsParam.ScreensSources.CreateWalletIntro,
|
||||
),
|
||||
onComplete = any(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import com.tangem.core.ui.extensions.conditionalCompose
|
|||
import com.tangem.core.ui.res.LocalHazeState
|
||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.utils.WindowInsetsZero
|
||||
import com.tangem.features.feed.components.FeedEntryChildFactory
|
||||
import com.tangem.features.feed.ui.utils.contentFeedEntryStackAnimation
|
||||
|
|
@ -138,7 +139,9 @@ private fun EntryContentV2(
|
|||
},
|
||||
)
|
||||
.hazeSourceTangem(zIndex = 0f, state = hazeState),
|
||||
contentPadding = PaddingValues(top = topBarHeight),
|
||||
contentPadding = PaddingValues(
|
||||
top = if (isOpenedInBottomSheet) topBarHeight else TangemTheme.dimens2.x2_5,
|
||||
),
|
||||
bottomSheetState = bottomSheetState,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
package com.tangem.features.swap
|
||||
|
||||
interface SwapFeatureToggles
|
||||
interface SwapFeatureToggles {
|
||||
val isSwapSwitchToTransferEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers
|
|||
import com.tangem.common.extensions.isZero
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.Basic
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent
|
||||
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
|
||||
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetPrimaryCurrencyTransformer
|
||||
import dagger.assisted.Assisted
|
||||
|
|
@ -70,7 +70,7 @@ internal class PrimaryCurrencySubscriber @AssistedInject constructor(
|
|||
cardBalanceState?.let { balanceState ->
|
||||
// do not send tokens count for single currency wallet
|
||||
analyticsEventHandler.send(
|
||||
event = WalletScreenAnalyticsEvent.Basic.BalanceLoaded(
|
||||
event = Basic.BalanceLoaded(
|
||||
balance = balanceState,
|
||||
tokensCount = null,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -209,16 +209,27 @@ private fun WalletContent2(
|
|||
.fillMaxSize()
|
||||
.hazeSourceTangem(zIndex = -2f),
|
||||
) {
|
||||
NorthernLightsBackground(
|
||||
containerColor = if (LocalIsInDarkTheme.current) {
|
||||
TangemTheme.colors2.surface.level1
|
||||
} else {
|
||||
TangemTheme.colors2.surface.level2
|
||||
},
|
||||
val backgroundColor = if (LocalIsInDarkTheme.current) {
|
||||
TangemTheme.colors2.surface.level1
|
||||
} else {
|
||||
TangemTheme.colors2.surface.level2
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = 1 - behavior.state.collapsedFraction * 2 }
|
||||
.matchParentSize(),
|
||||
.matchParentSize()
|
||||
.background(backgroundColor),
|
||||
)
|
||||
val isSheetExpanded by remember {
|
||||
derivedStateOf { bottomSheetState.targetValue == TangemSheetValue.Expanded }
|
||||
}
|
||||
if (!isSheetExpanded) {
|
||||
NorthernLightsBackground(
|
||||
containerColor = backgroundColor,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = 1 - behavior.state.collapsedFraction * 2 }
|
||||
.matchParentSize(),
|
||||
)
|
||||
}
|
||||
|
||||
WalletPagerIndicator(
|
||||
pagerState = walletsPagerState,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import com.tangem.common.ui.tokens.NonContentItemContent
|
|||
import com.tangem.common.ui.tokens.SlideInItemVisibility
|
||||
import com.tangem.core.ui.components.SpacerH
|
||||
import com.tangem.core.ui.components.account.AccountIconSize
|
||||
import com.tangem.core.ui.components.account.toBoxSize
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.tokenlist.NON_CONTENT_TOKENS_LIST_KEY
|
||||
import com.tangem.core.ui.components.tokenlist.TokenListItem
|
||||
|
|
@ -382,12 +383,14 @@ internal fun PortfolioRowItem(
|
|||
headIcon
|
||||
}
|
||||
|
||||
val iconBoxSize = when (headIcon) {
|
||||
is TangemIconUM.Empty -> TangemTheme.dimens2.x9
|
||||
else -> size.toBoxSize()
|
||||
}
|
||||
TangemIcon(
|
||||
tangemIconUM = sizedHeadIcon,
|
||||
modifier = modifier
|
||||
.conditionalCompose(headIcon is TangemIconUM.Empty) {
|
||||
size(TangemTheme.dimens2.x9)
|
||||
}
|
||||
.size(iconBoxSize)
|
||||
.sharedBounds(
|
||||
sharedContentState = iconSharedContentState,
|
||||
animatedVisibilityScope = animatedContentScope,
|
||||
|
|
|
|||
|
|
@ -19,10 +19,7 @@ import com.tangem.domain.card.ScanCardProcessor
|
|||
import com.tangem.domain.common.wallets.UserWalletsListRepository
|
||||
import com.tangem.domain.common.wallets.error.SaveWalletError
|
||||
import com.tangem.domain.common.wallets.error.UnlockWalletError
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.wallet.isImported
|
||||
import com.tangem.domain.models.wallet.isLocked
|
||||
import com.tangem.domain.models.wallet.*
|
||||
import com.tangem.domain.settings.CanUseBiometryUseCase
|
||||
import com.tangem.domain.settings.HotWalletRestrictionManager
|
||||
import com.tangem.domain.wallets.builder.ColdUserWalletBuilder
|
||||
|
|
@ -76,9 +73,9 @@ internal class WelcomeModel @Inject constructor(
|
|||
val userWallet = userWallets.first { it.walletId == walletId }
|
||||
trackingContextProxy.addContext(userWallet)
|
||||
val signInType = when {
|
||||
!userWallet.isLocked -> SignIn.ButtonWallet.SignInType.NoSecurity
|
||||
userWallet is UserWallet.Cold -> SignIn.ButtonWallet.SignInType.Card
|
||||
else -> SignIn.ButtonWallet.SignInType.AccessCode
|
||||
!userWallet.isLocked -> AnalyticsParam.SignInType.NoSecurity
|
||||
userWallet is UserWallet.Cold -> AnalyticsParam.SignInType.Card
|
||||
else -> AnalyticsParam.SignInType.AccessCode
|
||||
}
|
||||
analyticsEventHandler.send(
|
||||
event = SignIn.ButtonWallet(
|
||||
|
|
@ -259,7 +256,7 @@ internal class WelcomeModel @Inject constructor(
|
|||
if (userWallet.isLocked.not()) {
|
||||
// If the wallet is not locked, we can proceed to the wallet screen directly
|
||||
userWalletsListRepository.select(userWallet.walletId)
|
||||
trackSignInEvent(userWallet, Basic.SignedIn.SignInType.NoSecurity)
|
||||
trackSignInEvent(userWallet, AnalyticsParam.SignInType.NoSecurity)
|
||||
router.replaceAll(AppRoute.Wallet)
|
||||
return@launch
|
||||
}
|
||||
|
|
@ -320,19 +317,15 @@ internal class WelcomeModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun trackSignInEvent(userWallet: UserWallet, type: Basic.SignedIn.SignInType) {
|
||||
private suspend fun trackSignInEvent(userWallet: UserWallet, type: AnalyticsParam.SignInType) {
|
||||
val walletsCount = userWalletsListRepository.userWalletsSync().size
|
||||
trackingContextProxy.addContext(userWallet)
|
||||
val isBackedUp = when (userWallet) {
|
||||
is UserWallet.Cold -> userWallet.scanResponse.card.backupStatus?.isActive == true
|
||||
is UserWallet.Hot -> userWallet.backedUp
|
||||
}
|
||||
analyticsEventHandler.send(
|
||||
event = Basic.SignedIn(
|
||||
signInType = type,
|
||||
walletsCount = walletsCount,
|
||||
isImported = userWallet.isImported(),
|
||||
hasBackup = isBackedUp,
|
||||
isBackedUp = userWallet.isBackedUpForAnalytics(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue